[
  {
    "path": ".beads/interactions.jsonl",
    "content": ""
  },
  {
    "path": ".beads/issues.jsonl",
    "content": "{\"id\":\"subtrackr-xyz-1fb\",\"title\":\"Implement cancellation date email notifications (#88)\",\"description\":\"Add email notification option for X days before cancellation date. User wants reminders to cancel subscriptions before renewal. Fits with Pushover notifications already added in v0.5.5.\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"feature\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-01T18:32:20.604827-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-01T18:42:20.912338-08:00\",\"closed_at\":\"2026-02-01T18:42:20.912338-08:00\",\"close_reason\":\"Issue #88 implementation complete: cancellation date notifications fully implemented\"}\n{\"id\":\"subtrackr-xyz-255\",\"title\":\"Add cancellation reminder fields to models\",\"description\":\"Add CancellationReminders and CancellationReminderDays to NotificationSettings. Add LastCancellationReminderSent and LastCancellationReminderDate to Subscription model. Files: internal/models/settings.go, internal/models/subscription.go\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"task\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-01T18:33:40.632633-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-01T18:42:20.925084-08:00\",\"closed_at\":\"2026-02-01T18:42:20.925084-08:00\",\"close_reason\":\"Issue #88 implementation complete: cancellation date notifications fully implemented\"}\n{\"id\":\"subtrackr-xyz-3gh\",\"title\":\"Fix Tab and PWA icon missing (#84)\",\"description\":\"Tab favicon and PWA icon are not displaying in Firefox and Safari. Need to configure proper favicon/manifest icons.\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"bug\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-01-22T18:21:09.631203-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-01-22T18:26:02.363767-08:00\",\"closed_at\":\"2026-01-22T18:26:02.363767-08:00\",\"close_reason\":\"Implemented in v0.5.3\"}\n{\"id\":\"subtrackr-xyz-46o\",\"title\":\"Add CNY currency support (#95)\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"feature\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-11T11:36:30.831651-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-11T11:38:35.298071-08:00\",\"closed_at\":\"2026-02-11T11:38:35.298071-08:00\",\"close_reason\":\"Added CNY currency to SupportedCurrencies, GetCurrencySymbol, and settings template\"}\n{\"id\":\"subtrackr-xyz-4ma\",\"title\":\"Add repository method for upcoming cancellations\",\"description\":\"Add GetUpcomingCancellations(days int) method to query subscriptions with cancellation dates approaching. File: internal/repository/subscription.go\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"task\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-01T18:33:40.768444-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-01T18:42:20.95075-08:00\",\"closed_at\":\"2026-02-01T18:42:20.95075-08:00\",\"close_reason\":\"Issue #88 implementation complete: cancellation date notifications fully implemented\"}\n{\"id\":\"subtrackr-xyz-5ls\",\"title\":\"Add cancellation reminder service methods\",\"description\":\"Add GetSubscriptionsNeedingCancellationReminders(), SendCancellationReminder() to subscription, email, and pushover services. Files: internal/service/subscription.go, email.go, pushover.go\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"task\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-01T18:33:40.827689-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-01T18:40:33.13865-08:00\",\"closed_at\":\"2026-02-01T18:40:33.13865-08:00\",\"close_reason\":\"Added SendCancellationReminder methods to email and pushover services\",\"dependencies\":[{\"issue_id\":\"subtrackr-xyz-5ls\",\"depends_on_id\":\"subtrackr-xyz-255\",\"type\":\"blocks\",\"created_at\":\"2026-02-01T18:33:58.079731-08:00\",\"created_by\":\"Brian Scott\"},{\"issue_id\":\"subtrackr-xyz-5ls\",\"depends_on_id\":\"subtrackr-xyz-4ma\",\"type\":\"blocks\",\"created_at\":\"2026-02-01T18:33:58.148998-08:00\",\"created_by\":\"Brian Scott\"}]}\n{\"id\":\"subtrackr-xyz-8gh\",\"title\":\"Add cancellation reminder settings UI\",\"description\":\"Add settings handlers and UI for cancellation reminder preferences. Files: internal/handlers/settings.go, templates/settings.html\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"task\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-01T18:33:40.941374-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-01T18:42:16.382596-08:00\",\"closed_at\":\"2026-02-01T18:42:16.382596-08:00\",\"close_reason\":\"Added cancellation reminder settings UI to handlers and template\",\"dependencies\":[{\"issue_id\":\"subtrackr-xyz-8gh\",\"depends_on_id\":\"subtrackr-xyz-uzq\",\"type\":\"blocks\",\"created_at\":\"2026-02-01T18:33:58.280161-08:00\",\"created_by\":\"Brian Scott\"}]}\n{\"id\":\"subtrackr-xyz-cqi\",\"title\":\"Restore Backup feature (#107)\",\"status\":\"closed\",\"priority\":1,\"issue_type\":\"feature\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-04-15T18:58:37.02157-07:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-04-15T19:14:38.025112-07:00\",\"closed_at\":\"2026-04-15T19:14:38.025112-07:00\",\"close_reason\":\"Implemented restore backup feature with replace/merge modes, UI in settings page\"}\n{\"id\":\"subtrackr-xyz-lne\",\"title\":\"Add SSE/HTTP transport for MCP server\",\"description\":\"Currently the MCP server only supports stdio transport, requiring the AI client to spawn the process as a subprocess. Add SSE/HTTP transport option so the MCP server can run as an endpoint on the web server (e.g. /mcp), enabling remote AI clients to connect without needing local access to the binary or database. This would make Docker deployments much simpler for MCP integration.\",\"status\":\"open\",\"priority\":4,\"issue_type\":\"feature\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-11T16:05:44.116758-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-11T16:05:53.016576-08:00\"}\n{\"id\":\"subtrackr-xyz-lw5\",\"title\":\"Add database migration for cancellation tracking\",\"description\":\"Create migration to add last_cancellation_reminder_sent and last_cancellation_reminder_date columns to subscriptions table. File: internal/database/migrations.go\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"task\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-01T18:33:40.709756-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-01T18:42:20.938383-08:00\",\"closed_at\":\"2026-02-01T18:42:20.938383-08:00\",\"close_reason\":\"Issue #88 implementation complete: cancellation date notifications fully implemented\",\"dependencies\":[{\"issue_id\":\"subtrackr-xyz-lw5\",\"depends_on_id\":\"subtrackr-xyz-255\",\"type\":\"blocks\",\"created_at\":\"2026-02-01T18:33:57.997065-08:00\",\"created_by\":\"Brian Scott\"}]}\n{\"id\":\"subtrackr-xyz-tqp\",\"title\":\"Remember sorting preference (#85)\",\"description\":\"Persist user's subscription list sort preference to localStorage so it remembers their choice between sessions\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"feature\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-01-22T18:21:09.459913-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-01-22T18:26:02.362337-08:00\",\"closed_at\":\"2026-01-22T18:26:02.362337-08:00\",\"close_reason\":\"Implemented in v0.5.3\"}\n{\"id\":\"subtrackr-xyz-uzq\",\"title\":\"Add cancellation reminder scheduler\",\"description\":\"Add checkAndSendCancellationReminders() function and integrate with daily scheduler. File: cmd/server/main.go\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"task\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-02-01T18:33:40.884483-08:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-02-01T18:41:11.679802-08:00\",\"closed_at\":\"2026-02-01T18:41:11.679802-08:00\",\"close_reason\":\"Added cancellation reminder scheduler and checker functions to main.go\",\"dependencies\":[{\"issue_id\":\"subtrackr-xyz-uzq\",\"depends_on_id\":\"subtrackr-xyz-5ls\",\"type\":\"blocks\",\"created_at\":\"2026-02-01T18:33:58.214037-08:00\",\"created_by\":\"Brian Scott\"}]}\n{\"id\":\"subtrackr-xyz-z1c\",\"title\":\"Custom schedule intervals (#77, #75)\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"feature\",\"owner\":\"191290+bscott@users.noreply.github.com\",\"created_at\":\"2026-04-15T19:17:23.123268-07:00\",\"created_by\":\"Brian Scott\",\"updated_at\":\"2026-04-15T19:53:54.309102-07:00\",\"closed_at\":\"2026-04-15T19:53:54.309102-07:00\",\"close_reason\":\"Added custom schedule intervals with single dropdown UI - supports multi-year, biweekly, etc.\"}\n"
  },
  {
    "path": ".claude/commands/release.md",
    "content": "# Release $ARGUMENTS\n\nExecute the SubTrackr release workflow for version $ARGUMENTS.\n\n## Pre-flight\n\n1. Verify you're on the correct branch: `git branch --show-current` should be `$ARGUMENTS`\n2. If not, create and checkout: `git checkout -b $ARGUMENTS`\n3. Run `gh release list --limit 1` to confirm the previous version\n\n## Track Work\n\nCreate beads issues for each work item in this release:\n```bash\nbd create --title=\"Description (#GitHub-issue)\" --type=feature --priority=2\n```\n\n## Build & Test\n\n1. Run `go build ./cmd/server` to verify compilation\n2. Run `gofmt -l .` to check formatting — fix any issues with `gofmt -w`\n3. Run `go vet ./...` to check for issues\n4. Run `go test ./...` to verify all tests pass\n\n## Create Draft Release\n\n```bash\ngh release create $ARGUMENTS --draft --title \"$ARGUMENTS - Title\" --notes \"release notes here\"\n```\n\nWrite meaningful release notes covering what's new, bug fixes, and technical changes.\n\n## Commit & Push\n\n1. Stage changed files: `git add <specific files>`\n2. Commit with conventional format — NO AI attribution in commit messages:\n   ```\n   git commit -m \"$ARGUMENTS - Release Title\n\n   - Change 1\n   - Change 2\"\n   ```\n3. Push: `git push -u origin $ARGUMENTS`\n\n## Create Pull Request\n\n```bash\ngh pr create --title \"$ARGUMENTS - Title\" --body \"summary and test plan, Closes #issues\"\n```\n\n## Comment on Issues\n\nNotify issue reporters:\n```bash\ngh issue comment <number> --body \"Fixed in PR #XX. Description.\"\n```\n\n## After Merge (user tells you to publish)\n\n```bash\ngh release edit $ARGUMENTS --draft=false\ngh release view $ARGUMENTS\n```\n\nThe published tag triggers the Docker build workflow automatically.\n"
  },
  {
    "path": ".dockerignore",
    "content": "# Git files\n.git/\n.gitignore\n.github/\n\n# Documentation\n*.md\ndocs/\nLICENSE\nscreenshots/\n\n# Development files\ndocker-compose.yml\n.dockerignore\nDockerfile\n*.log\n.env*\n\n# Build artifacts\nsubtrackr\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n*.test\n*.out\nvendor/\ndist/\n\n# Test files\n*_test.go\ntestdata/\ncoverage.*\n\n# IDE files\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS files\nThumbs.db\n.DS_Store\n\n# Data directory\ndata/\n*.db\n*.sqlite\n*.sqlite3\n\n# Temporary files\n*.tmp\n*.temp\ntmp/\n\n# CI/CD\n.github/\n\n# Legacy files (no longer used)\nnode_modules/\nsrc/\npublic/\npackage*.json\ntsconfig.json\ntailwind.config*\npostcss.config*\nrebuild.sh\nremove-configs.sh\n\n# Media files\n*.png\n*.jpg\n*.jpeg\n*.gif\n*.svg\n*.ico"
  },
  {
    "path": ".gitattributes",
    "content": "\n# Use bd merge for beads JSONL files\n.beads/issues.jsonl merge=beads\n"
  },
  {
    "path": ".github/workflows/claude-code-review.yml",
    "content": "name: Claude Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize, ready_for_review, reopened]\n    # Optional: Only run on specific file changes\n    # paths:\n    #   - \"src/**/*.ts\"\n    #   - \"src/**/*.tsx\"\n    #   - \"src/**/*.js\"\n    #   - \"src/**/*.jsx\"\n\njobs:\n  claude-review:\n    # Optional: Filter by PR author\n    # if: |\n    #   github.event.pull_request.user.login == 'external-contributor' ||\n    #   github.event.pull_request.user.login == 'new-developer' ||\n    #   github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'\n\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: read\n      issues: read\n      id-token: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n\n      - name: Run Claude Code Review\n        id: claude-review\n        uses: anthropics/claude-code-action@v1\n        with:\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n          plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'\n          plugins: 'code-review@claude-code-plugins'\n          prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'\n          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md\n          # or https://code.claude.com/docs/en/cli-reference for available options\n\n"
  },
  {
    "path": ".github/workflows/claude.yml",
    "content": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issues:\n    types: [opened, assigned]\n  pull_request_review:\n    types: [submitted]\n\njobs:\n  claude:\n    if: |\n      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||\n      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: read\n      issues: read\n      id-token: write\n      actions: read # Required for Claude to read CI results on PRs\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n\n      - name: Run Claude Code\n        id: claude\n        uses: anthropics/claude-code-action@v1\n        with:\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n\n          # This is an optional setting that allows Claude to read CI results on PRs\n          additional_permissions: |\n            actions: read\n\n          # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.\n          # prompt: 'Update the pull request description to include a summary of changes.'\n\n          # Optional: Add claude_args to customize behavior and configuration\n          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md\n          # or https://code.claude.com/docs/en/cli-reference for available options\n          # claude_args: '--allowed-tools Bash(gh pr:*)'\n\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Build and Publish Docker Image\n\non:\n  push:\n    tags: [ 'v*' ]\n  workflow_dispatch:\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=semver,pattern={{version}}\n            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}\n\n      - name: Extract version info\n        id: version\n        run: |\n          if [[ \"${{ github.ref }}\" == refs/tags/* ]]; then\n            GIT_TAG=\"${{ github.ref_name }}\"\n          else\n            GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"dev\")\n          fi\n          GIT_COMMIT=$(git rev-parse --short HEAD)\n          echo \"tag=$GIT_TAG\" >> $GITHUB_OUTPUT\n          echo \"commit=$GIT_COMMIT\" >> $GITHUB_OUTPUT\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          build-args: |\n            BUILDKIT_INLINE_CACHE=1\n            GIT_TAG=${{ steps.version.outputs.tag }}\n            GIT_COMMIT=${{ steps.version.outputs.commit }}"
  },
  {
    "path": ".github/workflows/test-build.yml",
    "content": "name: Test Build\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - '**.go'\n      - 'go.mod'\n      - 'go.sum'\n      - 'Dockerfile'\n      - 'templates/**'\n      - 'web/**'\n      - '.github/workflows/**'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  test-build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: '1.21'\n\n      - name: Cache Go modules\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/go/pkg/mod\n            ~/.cache/go-build\n          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}\n          restore-keys: |\n            ${{ runner.os }}-go-\n\n      - name: Download dependencies\n        run: go mod download\n\n      - name: Verify dependencies\n        run: go mod verify\n\n      - name: Build application\n        run: go build -v -o subtrackr cmd/server/main.go\n\n      - name: Run tests\n        run: go test -v ./...\n\n      - name: Extract version info\n        id: version\n        run: |\n          if [[ \"${{ github.ref }}\" == refs/tags/* ]]; then\n            GIT_TAG=\"${{ github.ref_name }}\"\n          else\n            GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"dev\")\n          fi\n          GIT_COMMIT=$(git rev-parse --short HEAD)\n          echo \"tag=$GIT_TAG\" >> $GITHUB_OUTPUT\n          echo \"commit=$GIT_COMMIT\" >> $GITHUB_OUTPUT\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Test Docker build\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          platforms: linux/amd64\n          push: false\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          build-args: |\n            BUILDKIT_INLINE_CACHE=1\n            GIT_TAG=${{ steps.version.outputs.tag }}\n            GIT_COMMIT=${{ steps.version.outputs.commit }}"
  },
  {
    "path": ".gitignore",
    "content": "# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\nsubtrackr\nmain\n\n# Beads (keep issues.jsonl and interactions.jsonl for syncing)\n.beads/beads.db\n.beads/config.yaml\n.beads/metadata.json\n.beads/README.md\n.beads/.gitignore\n.beads/export-state/\n\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\n\n# Database files\ndata/\n*.db\n*.db-shm\n*.db-wal\n\n# Environment variables\n.env\n.env.local\n.env.production\n\n# IDE files\n.idea/\n.vscode/\n*.swp\n*.swo\n*~\n\n# Plans (not committed yet)\nplans/\n\n# OS files\n.DS_Store\nThumbs.db\n\n# Log files\n*.log\n\n# Temporary files\ntmp/\ntemp/\n\n# Build directories\ndist/\nbuild/\n\n# Docker volumes (if running locally)\ndocker-data/\n\n# Project specific\nproduct-spec.md\nCleanShot*.png\n.playwright-mcp/\nnode_modules/\nserver.log\nserver\n*.db\ndata/\nsubtrackr\n\n# Release notes (draft files, not committed)\nRELEASE_NOTES*.md\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# SubTrackr - Agent Documentation\n\n## Project Overview\n\nSubTrackr is a self-hosted subscription management application built with Go and HTMX. It helps users track subscriptions, visualize spending, and get renewal reminders.\n\n## Architecture\n\n### Tech Stack\n- **Backend**: Go 1.21+ with Gin web framework\n- **Database**: SQLite (GORM)\n- **Frontend**: HTMX + Tailwind CSS\n- **Deployment**: Docker & Docker Compose\n\n### Project Structure\n\n```\nsubtrackr-xyz/\n├── cmd/\n│   ├── server/          # Main server entry point\n│   └── migrate-dates/   # Date migration utility\n├── internal/\n│   ├── config/          # Configuration management\n│   ├── database/        # Database initialization and migrations\n│   ├── handlers/        # HTTP request handlers (Gin handlers)\n│   ├── middleware/      # HTTP middleware (auth, etc.)\n│   ├── models/          # Data models (GORM models)\n│   ├── repository/      # Data access layer\n│   ├── service/         # Business logic layer\n│   └── version/         # Version information\n├── templates/           # HTML templates (HTMX)\n├── web/static/          # Static assets (JS, CSS, images)\n├── tests/               # Playwright E2E tests\n└── data/                # SQLite database (gitignored)\n```\n\n### Key Components\n\n#### 1. Server Entry Point (`cmd/server/main.go`)\n- Initializes database, repositories, services, and handlers\n- Sets up Gin router with templates\n- Configures routes (web and API)\n- Starts HTTP server\n\n#### 2. Handlers (`internal/handlers/`)\n- **subscription.go**: CRUD operations for subscriptions\n- **settings.go**: SMTP config, Pushover config, notifications, API keys, currency, dark mode\n- **category.go**: Category management\n\n#### 3. Services (`internal/service/`)\n- Business logic layer\n- **subscription.go**: Subscription operations\n- **settings.go**: Settings management\n- **category.go**: Category operations\n- **currency.go**: Currency conversion (Fixer.io integration)\n- **email.go**: Email notification service (SMTP)\n- **pushover.go**: Pushover notification service\n\n#### 4. Models (`internal/models/`)\n- GORM models:\n  - `Subscription`: Main subscription entity\n  - `Category`: Subscription categories\n  - `Settings`: Application settings (key-value store)\n  - `SMTPConfig`: Email configuration\n  - `PushoverConfig`: Pushover notification configuration\n  - `APIKey`: API authentication keys\n  - `ExchangeRate`: Currency exchange rates\n\n#### 5. Repository (`internal/repository/`)\n- Data access layer using GORM\n- Abstracts database operations\n\n### Routing Structure\n\n#### Web Routes (HTMX)\n- `/` - Dashboard\n- `/dashboard` - Dashboard\n- `/subscriptions` - Subscription list\n- `/analytics` - Analytics view\n- `/settings` - Settings page\n- `/form/subscription` - Subscription form modal\n\n#### API Routes (HTMX)\n- `/api/subscriptions` - Subscription CRUD\n- `/api/stats` - Statistics\n- `/api/export/*` - Data export\n- `/api/settings/*` - Settings management\n- `/api/categories` - Category management\n\n#### Public API Routes (Require API Key)\n- `/api/v1/subscriptions` - Subscription CRUD\n- `/api/v1/stats` - Statistics\n- `/api/v1/export/*` - Data export\n\n### Database Schema\n\n#### Subscriptions\n- ID, Name, Cost, OriginalCurrency\n- Schedule: Monthly, Annual, Weekly, Daily\n- Status: Active, Cancelled, Paused, Trial\n- CategoryID (foreign key)\n- Dates: StartDate, RenewalDate, CancellationDate\n- Additional: PaymentMethod, Account, URL, Notes, Usage\n\n#### Categories\n- ID, Name\n- CreatedAt, UpdatedAt\n\n#### Settings\n- Key-value store for application settings\n- Keys: `smtp_config`, `renewal_reminders`, `currency`, etc.\n\n### Key Features\n\n1. **Subscription Management**\n   - CRUD operations\n   - Multiple schedules (Monthly, Annual, Weekly, Daily)\n   - Categories\n   - Multi-currency support\n\n2. **Email Notifications**\n   - SMTP configuration with TLS/SSL support\n   - STARTTLS for ports 2525, 8025, 587, 25, 80\n   - Implicit TLS for ports 465, 8465, 443\n   - Renewal reminders\n   - High cost alerts\n\n3. **Pushover Notifications**\n   - Pushover API integration for mobile push notifications\n   - User Key and Application Token configuration\n   - Renewal reminders (same settings as email)\n   - High cost alerts (same threshold as email)\n   - Works alongside email notifications\n\n4. **Currency Support**\n   - USD, EUR, GBP, JPY, RUB, SEK, PLN, INR, CHF, BRL, COP, BDT\n   - Optional Fixer.io integration for real-time rates\n   - Automatic conversion display\n   - BDT (Bangladeshi Taka) with ৳ symbol\n\n5. **API Access**\n   - API key authentication\n   - RESTful endpoints\n   - JSON responses\n\n5. **Data Management**\n   - CSV/JSON export\n   - Backup functionality\n   - Clear all data option\n\n### Development Guidelines\n\n#### Code Style\n- Follow Go standard formatting (`go fmt`)\n- Use meaningful variable and function names\n- Add comments for exported functions\n- Keep functions focused and small\n\n#### Error Handling\n- Return errors from functions, don't panic\n- Log errors appropriately\n- Provide user-friendly error messages in handlers\n\n#### Testing\n- Unit tests in `*_test.go` files\n- E2E tests in `tests/` using Playwright\n- Test API endpoints with `test-api.sh`\n\n#### Database Migrations\n- Migrations in `internal/database/migrations.go`\n- Use GORM AutoMigrate for schema changes\n- Test migrations on sample data\n\n#### Frontend\n- Use HTMX for dynamic updates\n- Tailwind CSS for styling\n- Dark mode support via class-based switching\n- Mobile-responsive design\n\n### Recent Changes\n\n#### v0.5.3 - Sort Persistence and PWA Support\n- Remember sorting preference (#85) - localStorage persistence\n- Fix Tab and PWA icon missing (#84) - favicon, apple-touch-icon, manifest.json\n- Input validation for sort parameters\n- PWA meta tags on all HTML templates\n\n#### v0.5.2 - Currency Improvements\n- Enhanced currency support and conversion display\n\n#### v0.5.1 - Dark Classic Theme and Calendar Fixes\n- Dark classic theme option\n- Calendar view improvements\n\n#### v0.5.0 - Optional Login Support\n- Optional authentication system\n- Beautiful theme options\n\n### Release Workflow\n\nThis project uses versioned branches for releases. See `CLAUDE.md` for the complete workflow.\n\n**Quick Reference:**\n1. Create versioned branch: `git checkout -b vX.Y.Z`\n2. Track work with beads: `bd create`, `bd update`, `bd close`\n3. Create draft release: `gh release create vX.Y.Z --draft`\n4. Run code review agent before committing\n5. Commit, push, create PR: `gh pr create`\n6. Comment on GitHub issues: `gh issue comment`\n7. Monitor CI: `gh run watch`\n8. Merge PR: `gh pr merge --merge --delete-branch`\n9. Publish release: `gh release edit vX.Y.Z --draft=false`\n\n### Common Tasks\n\n#### Adding a New Feature\n1. Create/update model in `internal/models/`\n2. Add repository methods in `internal/repository/`\n3. Add service logic in `internal/service/`\n4. Create handler in `internal/handlers/`\n5. Add routes in `cmd/server/main.go`\n6. Update templates if needed\n7. Add tests\n\n#### Adding a New Schedule Type\n1. Update `Subscription.Schedule` validation in `internal/models/subscription.go`\n2. Update `AnnualCost()` and `MonthlyCost()` methods\n3. Update frontend templates to include new option\n4. Update date calculation logic if needed\n\n#### Adding a New Currency\n1. Add currency code to `SupportedCurrencies` in `internal/service/currency.go`\n2. Add currency symbol mapping in `GetCurrencySymbol()` in `internal/service/settings.go`\n3. Add currency option to currency selection in `templates/settings.html`\n4. Update exchange rate handling if using Fixer.io\n\n#### Adding a New Notification Method\n1. Create notification config model in `internal/models/settings.go`\n2. Create notification service in `internal/service/` (e.g., `pushover.go`)\n3. Add config save/get methods to `SettingsService`\n4. Add handlers in `internal/handlers/settings.go`\n5. Add UI in `templates/settings.html`\n6. Update subscription handler to send notifications\n7. Update renewal reminder scheduler in `cmd/server/main.go`\n\n### Environment Variables\n\n- `PORT` - Server port (default: 8080)\n- `DATABASE_PATH` - SQLite database path (default: ./data/subtrackr.db)\n- `GIN_MODE` - Gin mode: debug/release (default: debug)\n- `FIXER_API_KEY` - Fixer.io API key for currency conversion (optional)\n\n### Building and Running\n\n```bash\n# Development\ngo run cmd/server/main.go\n\n# Build\ngo build -o subtrackr cmd/server/main.go\n\n# Docker\ndocker-compose up -d --build\n```\n\n### Testing\n\n```bash\n# Run Go tests\ngo test ./...\n\n# Run E2E tests\nnpm test\n\n# Test API\n./test-api.sh\n```\n\n\n## Landing the Plane (Session Completion)\n\n**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.\n\n**MANDATORY WORKFLOW:**\n\n1. **File issues for remaining work** - Create issues for anything that needs follow-up\n2. **Run quality gates** (if code changed) - Tests, linters, builds\n3. **Update issue status** - Close finished work, update in-progress items\n4. **PUSH TO REMOTE** - This is MANDATORY:\n   ```bash\n   git pull --rebase\n   bd sync\n   git push\n   git status  # MUST show \"up to date with origin\"\n   ```\n5. **Clean up** - Clear stashes, prune remote branches\n6. **Verify** - All changes committed AND pushed\n7. **Hand off** - Provide context for next session\n\n**CRITICAL RULES:**\n- Work is NOT complete until `git push` succeeds\n- NEVER stop before pushing - that leaves work stranded locally\n- NEVER say \"ready to push when you are\" - YOU must push\n- If push fails, resolve and retry until it succeeds\n\n<!-- BEGIN BEADS INTEGRATION -->\n## Issue Tracking with bd (beads)\n\n**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.\n\n### Why bd?\n\n- Dependency-aware: Track blockers and relationships between issues\n- Git-friendly: Auto-syncs to JSONL for version control\n- Agent-optimized: JSON output, ready work detection, discovered-from links\n- Prevents duplicate tracking systems and confusion\n\n### Quick Start\n\n**Check for ready work:**\n\n```bash\nbd ready --json\n```\n\n**Create new issues:**\n\n```bash\nbd create \"Issue title\" --description=\"Detailed context\" -t bug|feature|task -p 0-4 --json\nbd create \"Issue title\" --description=\"What this issue is about\" -p 1 --deps discovered-from:bd-123 --json\n```\n\n**Claim and update:**\n\n```bash\nbd update bd-42 --status in_progress --json\nbd update bd-42 --priority 1 --json\n```\n\n**Complete work:**\n\n```bash\nbd close bd-42 --reason \"Completed\" --json\n```\n\n### Issue Types\n\n- `bug` - Something broken\n- `feature` - New functionality\n- `task` - Work item (tests, docs, refactoring)\n- `epic` - Large feature with subtasks\n- `chore` - Maintenance (dependencies, tooling)\n\n### Priorities\n\n- `0` - Critical (security, data loss, broken builds)\n- `1` - High (major features, important bugs)\n- `2` - Medium (default, nice-to-have)\n- `3` - Low (polish, optimization)\n- `4` - Backlog (future ideas)\n\n### Workflow for AI Agents\n\n1. **Check ready work**: `bd ready` shows unblocked issues\n2. **Claim your task**: `bd update <id> --status in_progress`\n3. **Work on it**: Implement, test, document\n4. **Discover new work?** Create linked issue:\n   - `bd create \"Found bug\" --description=\"Details about what was found\" -p 1 --deps discovered-from:<parent-id>`\n5. **Complete**: `bd close <id> --reason \"Done\"`\n\n### Auto-Sync\n\nbd automatically syncs with git:\n\n- Exports to `.beads/issues.jsonl` after changes (5s debounce)\n- Imports from JSONL when newer (e.g., after `git pull`)\n- No manual export/import needed!\n\n### Important Rules\n\n- ✅ Use bd for ALL task tracking\n- ✅ Always use `--json` flag for programmatic use\n- ✅ Link discovered work with `discovered-from` dependencies\n- ✅ Check `bd ready` before asking \"what should I work on?\"\n- ❌ Do NOT create markdown TODO lists\n- ❌ Do NOT use external issue trackers\n- ❌ Do NOT duplicate tracking systems\n\nFor more details, see README.md and docs/QUICKSTART.md.\n\n<!-- END BEADS INTEGRATION -->\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# SubTrackr - Claude Code Instructions\n\n## Release Workflow\n\nThis project uses versioned branches for releases. Follow this workflow when working on new features or bug fixes.\n\n### 1. Create a Versioned Branch\n\n```bash\n# Check current version\ngh release list --limit 1\n\n# Create and checkout versioned branch\ngit checkout -b v0.X.Y\n```\n\n### 2. Track Work with Beads\n\n```bash\n# Create beads issues for work items\nbd create --title=\"Feature description (#GitHub-issue)\" --type=feature --priority=2\n\n# Update status when starting work\nbd update <issue-id> --status=in_progress\n\n# Close when complete\nbd close <issue-id> --reason=\"Implemented in vX.Y.Z\"\n```\n\n### 3. Create Draft Release Before Committing\n\n```bash\n# Create draft release with release notes\ngh release create vX.Y.Z --draft --title \"vX.Y.Z - Release Title\" --notes \"$(cat <<'EOF'\n## What's New\n\n### Feature Name (#issue)\n- Description of changes\n\n## Technical Changes\n- List of technical changes\nEOF\n)\"\n```\n\n### 4. Code Review\n\nBefore committing, run the code review agent:\n- Check for code quality issues\n- Verify security concerns\n- Ensure best practices\n\n### 5. Commit and Push\n\n```bash\n# Stage changes\ngit add <files>\n\n# Commit with descriptive message\ngit commit -m \"vX.Y.Z - Release Title\n\n- Change 1\n- Change 2\"\n\n# Push branch\ngit push -u origin vX.Y.Z\n```\n\n### 6. Create Pull Request\n\n```bash\ngh pr create --title \"vX.Y.Z - Release Title\" --body \"$(cat <<'EOF'\n## Summary\n- Change summary\n\n## Test Plan\n- [ ] Test item 1\n- [ ] Test item 2\n\nCloses #issue1\nCloses #issue2\nEOF\n)\"\n```\n\n### 7. Comment on GitHub Issues\n\n```bash\n# Notify issue reporters\ngh issue comment <issue-number> --body \"Fixed in PR #XX. Description of fix.\"\n```\n\n### 8. Monitor CI and Merge\n\n```bash\n# Watch GitHub Actions\ngh run watch <run-id> --exit-status\n\n# Merge when CI passes\ngh pr merge <pr-number> --merge --delete-branch\n\n# Switch to main\ngit checkout main && git pull\n```\n\n### 9. Publish Release\n\n```bash\n# Publish the draft release\ngh release edit vX.Y.Z --draft=false\n\n# Verify\ngh release view vX.Y.Z\n```\n\n## Beads Integration\n\nThis project uses beads for local issue tracking across sessions.\n\n### Files\n- `.beads/issues.jsonl` - Issue data (committed)\n- `.beads/interactions.jsonl` - Audit log (committed)\n- `.beads/beads.db` - Local cache (gitignored)\n\n### Commands\n- `bd ready` - Find available work\n- `bd create` - Create new issue\n- `bd update` - Update issue status\n- `bd close` - Close completed issues\n- `bd sync --from-main` - Sync from main branch\n\n## Git Commit Guidelines\n\n- Do not include AI attribution in commit messages\n- Use conventional commit format\n- Keep messages clear and descriptive\n- Reference GitHub issue numbers where applicable\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Build stage\nFROM golang:1.24 AS builder\n\n# Install build dependencies\nRUN apt-get update && apt-get install -y \\\n    gcc \\\n    libc6-dev \\\n    libsqlite3-dev \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# Copy go mod files first for better caching\nCOPY go.mod go.sum ./\nRUN go mod download && go mod verify\n\n# Copy only necessary source directories\nCOPY cmd/ ./cmd/\nCOPY internal/ ./internal/\n\n# Build arguments for version info (should be provided by CI/CD)\nARG GIT_TAG=dev\nARG GIT_COMMIT=unknown\n\n# Build the application with optimizations and version info\n# Use build args directly - no need for .git directory\nRUN CGO_ENABLED=1 GOOS=linux go build \\\n    -ldflags=\"-w -s -X 'subtrackr/internal/version.Version=${GIT_TAG}' -X 'subtrackr/internal/version.GitCommit=${GIT_COMMIT}'\" \\\n    -o subtrackr ./cmd/server\n\n# Build the MCP server binary\nRUN CGO_ENABLED=1 GOOS=linux go build \\\n    -ldflags=\"-w -s -X 'subtrackr/internal/version.Version=${GIT_TAG}' -X 'subtrackr/internal/version.GitCommit=${GIT_COMMIT}'\" \\\n    -o subtrackr-mcp ./cmd/mcp\n\n# Final stage\nFROM debian:bookworm-slim\n\n# Install runtime dependencies in a single layer\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    ca-certificates \\\n    curl \\\n    sqlite3 \\\n    tzdata \\\n    && rm -rf /var/lib/apt/lists/* \\\n    && mkdir -p /app/data\n\nWORKDIR /app\n\n# Copy the binaries from builder\nCOPY --from=builder /app/subtrackr .\nCOPY --from=builder /app/subtrackr-mcp .\n\n# Copy templates and static assets\nCOPY templates/ ./templates/\nCOPY web/ ./web/\n\n# Expose port\nEXPOSE 8080\n\n# Set environment variables\nENV GIN_MODE=release\nENV DATABASE_PATH=/app/data/subtrackr.db\n\n# Healthcheck to verify the application is running and database is accessible\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n    CMD curl -f http://localhost:8080/healthz || exit 1\n\n# Run the application\nCMD [\"./subtrackr\"]"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "MIGRATION_v0.3.0.md",
    "content": "# Migration Guide for SubTrackr v0.3.0\n\n## Overview\n\nSubTrackr v0.3.0 introduces a new dynamic categories system that replaces the previous hardcoded category strings with a flexible database-driven approach. This guide will help you migrate your existing installation to v0.3.0.\n\n## What's New\n\n- **Dynamic Categories**: Categories are now stored in a separate database table\n- **Category Management UI**: Add, edit, and delete categories from the Settings page\n- **Foreign Key Relationships**: Subscriptions now reference categories by ID\n- **Additional Schedule Options**: Support for Weekly and Daily subscription schedules\n\n## Migration Steps\n\n### 1. Backup Your Data\n\nBefore upgrading, make sure to backup your existing data:\n\n```bash\n# From the SubTrackr settings page, use the \"Create Backup\" button\n# Or use the API:\ncurl -H \"Authorization: Bearer YOUR_API_KEY\" \\\n  http://localhost:8080/api/backup > subtrackr_backup.json\n```\n\n### 2. Update to v0.3.0\n\n```bash\n# Pull the latest changes\ngit pull origin v0.3.0\n\n# Or download the v0.3.0 release\n```\n\n### 3. Restart SubTrackr\n\nWhen you restart SubTrackr after updating:\n\n1. The database schema will automatically migrate\n2. Default categories will be created: Entertainment, Productivity, Storage, Software, Fitness, Education, Food, Travel, Business, Other\n3. Existing subscriptions will be mapped to the new category system\n\n### 4. Verify Migration\n\nAfter restarting:\n\n1. Check that all your subscriptions are still visible\n2. Verify that categories have been properly assigned\n3. Visit Settings → Categories to manage your categories\n\n## API Changes\n\nIf you're using the SubTrackr API, note these changes:\n\n### Creating/Updating Subscriptions\n\n**Before (v0.2.x):**\n```json\n{\n  \"name\": \"Netflix\",\n  \"cost\": 15.99,\n  \"schedule\": \"Monthly\",\n  \"status\": \"Active\",\n  \"category\": \"Entertainment\"\n}\n```\n\n**After (v0.3.0):**\n```json\n{\n  \"name\": \"Netflix\",\n  \"cost\": 15.99,\n  \"schedule\": \"Monthly\",\n  \"status\": \"Active\",\n  \"category_id\": 1\n}\n```\n\n### Getting Category IDs\n\nTo get the list of available categories and their IDs:\n\n```bash\ncurl http://localhost:8080/api/categories\n```\n\n## New Features\n\n### Schedule Options\n\nv0.3.0 adds support for Weekly and Daily schedules in addition to Monthly and Annual:\n\n- **Weekly**: Billed every 7 days\n- **Daily**: Billed every day\n\n### Category Management\n\n- Add custom categories for better organization\n- Edit category names\n- Delete unused categories (only if no subscriptions are using them)\n\n## Troubleshooting\n\n### Issue: Categories not showing after upgrade\n\n**Solution**: The categories should be automatically created on first run. If not, manually create them in Settings → Categories.\n\n### Issue: API calls failing with category errors\n\n**Solution**: Update your API calls to use `category_id` instead of `category`. Get the category IDs from the `/api/categories` endpoint.\n\n### Issue: Cannot delete a category\n\n**Solution**: Categories with active subscriptions cannot be deleted. First reassign or delete the subscriptions using that category.\n\n## Need Help?\n\nIf you encounter any issues during migration:\n\n1. Check the server logs for error messages\n2. Restore from your backup if needed\n3. Report issues at: https://github.com/bscott/subtrackr/issues"
  },
  {
    "path": "Makefile",
    "content": "# Variables\nGIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo \"unknown\")\nGIT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null || echo \"dev\")\nBUILD_TIME := $(shell date -u +\"%Y-%m-%dT%H:%M:%SZ\")\nLDFLAGS := -X 'subtrackr/internal/version.GitCommit=$(GIT_COMMIT)' -X 'subtrackr/internal/version.Version=$(GIT_TAG)'\n\n# Default target\n.PHONY: all\nall: build\n\n# Build the application\n.PHONY: build\nbuild:\n\tgo build -ldflags \"$(LDFLAGS)\" -o subtrackr cmd/server/main.go\n\n# Run the application\n.PHONY: run\nrun: build\n\t./subtrackr\n\n# Clean build artifacts\n.PHONY: clean\nclean:\n\trm -f subtrackr\n\n# Development mode with live reload (requires air)\n.PHONY: dev\ndev:\n\tair\n\n# Run tests\n.PHONY: test\ntest:\n\tgo test ./...\n\n# Run go vet\n.PHONY: vet\nvet:\n\tgo vet ./...\n\n# Run go fmt\n.PHONY: fmt\nfmt:\n\tgo fmt ./...\n\n# Build for multiple platforms\n.PHONY: build-all\nbuild-all:\n\tGOOS=darwin GOARCH=amd64 go build -ldflags \"$(LDFLAGS)\" -o dist/subtrackr-darwin-amd64 cmd/server/main.go\n\tGOOS=darwin GOARCH=arm64 go build -ldflags \"$(LDFLAGS)\" -o dist/subtrackr-darwin-arm64 cmd/server/main.go\n\tGOOS=linux GOARCH=amd64 go build -ldflags \"$(LDFLAGS)\" -o dist/subtrackr-linux-amd64 cmd/server/main.go\n\tGOOS=linux GOARCH=arm64 go build -ldflags \"$(LDFLAGS)\" -o dist/subtrackr-linux-arm64 cmd/server/main.go\n\tGOOS=windows GOARCH=amd64 go build -ldflags \"$(LDFLAGS)\" -o dist/subtrackr-windows-amd64.exe cmd/server/main.go\n\n.PHONY: help\nhelp:\n\t@echo \"Available targets:\"\n\t@echo \"  make build    - Build the application with git commit SHA\"\n\t@echo \"  make run      - Build and run the application\"\n\t@echo \"  make clean    - Remove build artifacts\"\n\t@echo \"  make test     - Run tests\"\n\t@echo \"  make vet      - Run go vet\"\n\t@echo \"  make fmt      - Format code\"\n\t@echo \"  make build-all - Build for multiple platforms\"\n\t@echo \"  make help     - Show this help message\""
  },
  {
    "path": "PLAN-login-settings.md",
    "content": "# Plan: Optional Login Support in Settings\n\n## Overview\n\nAdd optional authentication to SubTrackr that can be enabled/disabled from the Settings menu. This must be backward-compatible with existing single-user installations.\n\n---\n\n## Confirmed Decisions\n\n| Decision | Choice | Rationale |\n|----------|--------|-----------|\n| Login toggle location | Settings page | All config in one place |\n| Default state | **OFF** | No breaking changes for existing/new installs |\n| Scope | Single-user auth | Self-hosted personal tool, no multi-user needed |\n\n---\n\n## Current State Analysis\n\n### What Exists\n- **No authentication**: App assumes single user, all routes public\n- **API Key auth**: Already exists for `/api/v1/*` routes (external access)\n- **Settings infrastructure**: Key-value store in SQLite, well-structured service layer\n- **Repository pattern**: Clean separation of concerns ready for extension\n\n### Key Files to Modify\n- `internal/handlers/settings.go` - Add login settings handlers\n- `internal/service/settings.go` - Add auth settings management\n- `internal/middleware/auth.go` - Extend with session-based auth\n- `internal/database/migrations.go` - Add user table migration\n- `internal/models/` - Add User model\n- `templates/settings.html` - Add login configuration section\n- `cmd/server/main.go` - Conditional middleware application\n\n---\n\n## Design Decisions\n\n### 1. Authentication Model: **Optional Single-User Auth**\n\n**Rationale**: SubTrackr is designed as a self-hosted personal tool. Multi-user support adds complexity without clear benefit.\n\n**Approach**:\n- Single admin account (username + password)\n- No user registration - admin sets credentials in settings\n- Session-based auth using secure cookies\n- Login can be enabled/disabled at any time\n\n### 2. Settings-Based Toggle\n\n**New Settings Keys**:\n```\nauth_enabled            (bool)   - Master toggle for login requirement\nauth_username           (string) - Admin username\nauth_password_hash      (string) - bcrypt hash of password\nauth_session_secret     (string) - Secret for signing session cookies\nauth_reset_token        (string) - Temporary password reset token (cleared after use)\nauth_reset_token_expiry (string) - Reset token expiration timestamp\n```\n\n### 3. State Diagram\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    INSTALLATION STATES                       │\n├─────────────────────────────────────────────────────────────┤\n│                                                              │\n│  [Existing Install]              [New Install]               │\n│        │                              │                      │\n│        ▼                              ▼                      │\n│  auth_enabled = false           auth_enabled = false         │\n│  (no credentials set)           (no credentials set)         │\n│        │                              │                      │\n│        │  User enables auth           │                      │\n│        │  in Settings                 │                      │\n│        ▼                              ▼                      │\n│  ┌──────────────┐              ┌──────────────┐             │\n│  │ Setup Mode   │              │ Setup Mode   │             │\n│  │ - Set user   │              │ - Set user   │             │\n│  │ - Set pass   │              │ - Set pass   │             │\n│  └──────────────┘              └──────────────┘             │\n│        │                              │                      │\n│        ▼                              ▼                      │\n│  auth_enabled = true            auth_enabled = true          │\n│  (credentials set)              (credentials set)            │\n│        │                              │                      │\n│        ▼                              ▼                      │\n│  All routes protected           All routes protected         │\n│  Login page required            Login page required          │\n│                                                              │\n└─────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Impact on Existing Installations\n\n### Zero Breaking Changes Guarantee\n\n| Scenario | Current Behavior | After Update |\n|----------|------------------|--------------|\n| Fresh install | No auth | No auth (unchanged) |\n| Existing install | No auth | No auth (unchanged) |\n| User enables auth | N/A | Prompted to set credentials |\n| User disables auth | N/A | Returns to open access |\n\n### Migration Strategy\n\n1. **No automatic migration** - auth stays disabled by default\n2. **No forced password creation** - user must opt-in\n3. **Settings page accessible** - even without auth, settings remain accessible to allow setup\n4. **Graceful fallback** - if session expires, redirect to login (not error)\n\n---\n\n## Implementation Approach (Confirmed: Settings-First)\n\n**Flow**:\n1. Add \"Security\" section to Settings page\n2. Toggle \"Require Login\" is **OFF by default**\n3. **Prerequisite check**: SMTP must be configured before login can be enabled\n4. When user enables toggle, form expands to set username/password\n5. After credentials saved, auth middleware activates\n6. User must login on next page navigation\n\n**SMTP Prerequisite Requirement**:\n- Login toggle is disabled/grayed out until SMTP is configured and tested\n- Shows message: \"Configure email settings above to enable password recovery\"\n- This ensures users always have a \"Forgot Password\" recovery path\n- Prevents lockout scenarios where user has no way to reset password\n\n**Benefits**:\n- All configuration in one place (no env vars required)\n- No separate setup wizard needed\n- Easy to disable if locked out (just toggle off)\n- Zero impact on existing installations until user opts in\n- **Password recovery always available** via email\n\n**Optional: Environment Variable Override** (for advanced users)\n\nFor Docker deployments where UI access isn't preferred:\n```\nAUTH_ENABLED=true|false     # Override toggle (optional)\nAUTH_USERNAME=admin         # Only used with AUTH_ENABLED=true\nAUTH_PASSWORD=securepass    # Hashed on first server start\n```\nWhen env vars are set, Settings UI shows read-only status.\n\n---\n\n## Security Considerations\n\n### Password Storage\n- **bcrypt** with cost factor 12+\n- Never store plain text passwords\n- Environment variable passwords hashed on first server start\n\n### Session Management\n- **Secure cookies** with HttpOnly, SameSite=Strict\n- Session timeout: 24 hours (configurable)\n- Session secret auto-generated if not provided\n- CSRF protection via SameSite cookies + HTMX headers\n\n### Protected Routes (when auth enabled)\n```\nProtected:\n  /                    - Dashboard\n  /subscriptions       - Subscription list\n  /analytics           - Analytics\n  /calendar            - Calendar\n  /api/subscriptions/* - Internal API\n  /api/settings/*      - Settings API (except login)\n\nUnprotected:\n  /login               - Login page\n  /api/auth/login      - Login endpoint\n  /api/v1/*            - External API (uses API keys)\n  /static/*            - Static assets\n```\n\n### Lockout Recovery\n\n**Problem**: User forgets password, locked out of app\n\n**Solutions** (in order of preference):\n1. **Forgot Password email** (primary): Click \"Forgot Password\" on login page, receive reset link via SMTP\n2. **CLI reset command** (Docker-friendly): Run container with `--reset-password` flag\n3. **Environment override**: Set `AUTH_PASSWORD=newpassword` and restart\n4. **Database direct edit**: Delete `auth_password_hash` row from settings table\n5. **Data directory backup/restore**: Restore from backup without auth\n\n**Note**: SMTP is required before enabling login, ensuring option #1 is always available.\n\n---\n\n## CLI Password Reset Option\n\nFor Docker deployments where direct database access is inconvenient, provide a CLI flag to reset credentials.\n\n### Usage\n\n```bash\n# Reset password interactively (prompts for new password)\ndocker exec -it subtrackr /app/subtrackr --reset-password\n\n# Reset password non-interactively (for scripts)\ndocker exec -it subtrackr /app/subtrackr --reset-password --new-password \"newsecurepass\"\n\n# Disable authentication entirely (removes all auth settings)\ndocker exec -it subtrackr /app/subtrackr --disable-auth\n```\n\n### Docker Compose Example\n\n```yaml\n# One-time password reset (run separately, not in main compose)\ndocker compose run --rm subtrackr --reset-password\n```\n\n### Implementation Details\n\n**New CLI flags** (in `cmd/server/main.go`):\n```\n--reset-password       Resets admin password (interactive or with --new-password)\n--new-password <pass>  New password (used with --reset-password, skips prompt)\n--disable-auth         Disables authentication, removes credentials\n```\n\n**Behavior**:\n1. Parse flags before starting HTTP server\n2. If reset flag present:\n   - Connect to database\n   - Prompt for new password (or use --new-password value)\n   - Hash with bcrypt and update `auth_password_hash` setting\n   - Print success message and exit (don't start server)\n3. If disable-auth flag present:\n   - Delete all `auth_*` settings from database\n   - Print confirmation and exit\n\n**Security considerations**:\n- `--new-password` in process list is visible; recommend interactive mode when possible\n- These flags only work with direct container access (not exposed via API)\n- Log password reset events for audit trail\n\n---\n\n## Database Changes\n\n### No New Tables Required\n\nUsing existing `settings` table for auth configuration:\n\n```sql\n-- New settings rows (only created when auth enabled)\nINSERT INTO settings (key, value) VALUES\n  ('auth_enabled', 'true'),\n  ('auth_username', 'admin'),\n  ('auth_password_hash', '$2a$12$...'),  -- bcrypt hash\n  ('auth_session_secret', 'random-64-char-string');\n```\n\n**Why not a users table?**\n- Single-user design doesn't need it\n- Simpler migration path\n- Settings table already handles typed values well\n- Avoids foreign key complexity\n\n---\n\n## UI/UX Design\n\n### Settings Page Addition\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Settings                                                     │\n├─────────────────────────────────────────────────────────────┤\n│                                                              │\n│ ▼ Data Management                                           │\n│   [Export] [Backup] [Clear Data]                            │\n│                                                              │\n│ ▼ Email Notifications                                       │\n│   [...existing SMTP settings...]                            │\n│                                                              │\n│ ▼ Security  ← NEW SECTION                                   │\n│   ┌─────────────────────────────────────────────────────┐  │\n│   │ Require Login                          [Toggle OFF] │  │\n│   │                                                      │  │\n│   │ ┌─ When enabled: ────────────────────────────────┐  │  │\n│   │ │ Username: [________________]                    │  │  │\n│   │ │ Password: [________________]                    │  │  │\n│   │ │ Confirm:  [________________]                    │  │  │\n│   │ │                                                 │  │  │\n│   │ │ Session Timeout: [24] hours                     │  │  │\n│   │ │                                                 │  │  │\n│   │ │ [Save Credentials]                              │  │  │\n│   │ └─────────────────────────────────────────────────┘  │  │\n│   │                                                      │  │\n│   │ ⓘ When login is required, you'll need to sign in    │  │\n│   │   to access SubTrackr. API keys still work for      │  │\n│   │   external integrations.                            │  │\n│   └─────────────────────────────────────────────────────┘  │\n│                                                              │\n│ ▼ Appearance                                                │\n│   Dark Mode [Toggle]                                        │\n│                                                              │\n│ ▼ Currency                                                  │\n│   [...currency options...]                                  │\n│                                                              │\n│ ▼ API Keys                                                  │\n│   [...existing API key management...]                       │\n│                                                              │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### Login Page Design\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                                                              │\n│                      SubTrackr Logo                          │\n│                                                              │\n│              ┌──────────────────────────┐                   │\n│              │ Username                 │                   │\n│              │ [____________________]   │                   │\n│              │                          │                   │\n│              │ Password                 │                   │\n│              │ [____________________]   │                   │\n│              │                          │                   │\n│              │ [ ] Remember me          │                   │\n│              │                          │                   │\n│              │      [  Sign In  ]       │                   │\n│              │                          │                   │\n│              │    [Forgot Password?]    │                   │\n│              └──────────────────────────┘                   │\n│                                                              │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### Forgot Password Page Design\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                                                              │\n│                      SubTrackr Logo                          │\n│                                                              │\n│              ┌──────────────────────────┐                   │\n│              │ Reset Your Password      │                   │\n│              │                          │                   │\n│              │ A reset link will be     │                   │\n│              │ sent to your configured  │                   │\n│              │ email address.           │                   │\n│              │                          │                   │\n│              │   [  Send Reset Link  ]  │                   │\n│              │                          │                   │\n│              │   [Back to Login]        │                   │\n│              └──────────────────────────┘                   │\n│                                                              │\n└─────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Implementation Steps\n\n### Phase 1: Backend Foundation\n1. Add bcrypt dependency for password hashing\n2. Create auth settings methods in SettingsService\n3. Implement session management (cookie-based)\n4. Create login/logout handlers\n5. Create auth middleware that checks session\n\n### Phase 2: Settings UI\n6. Add Security section to settings.html\n7. Implement credential form with HTMX\n8. Add toggle state management\n9. Handle auth enable/disable flow\n\n### Phase 3: Login Page & Password Reset\n10. Create login.html template\n11. Implement login form with HTMX\n12. Add error handling (wrong password, etc.)\n13. Add redirect after login\n14. Create forgot-password.html template\n15. Implement password reset email sending (uses existing EmailService)\n16. Create reset-password.html template for setting new password\n17. Handle reset token generation, validation, and expiration\n\n### Phase 4: Route Protection\n18. Apply auth middleware conditionally\n19. Handle redirect to login for protected routes\n20. Ensure API keys still work independently\n\n### Phase 5: CLI Recovery Tools\n21. Add `--reset-password` flag to main.go\n22. Add `--new-password` flag for non-interactive reset\n23. Add `--disable-auth` flag to remove all auth settings\n24. Implement interactive password prompt (when no --new-password)\n\n### Phase 6: Testing & Edge Cases\n25. Test existing installations (no regression)\n26. Test enable/disable flow\n27. Test password reset flow via email\n28. Test CLI password reset (interactive and non-interactive)\n29. Test lockout recovery scenarios\n30. Test session timeout\n31. Update documentation\n\n---\n\n## Open Questions\n\n1. **Session storage**: In-memory (simple, lost on restart) vs SQLite (persistent)?\n   - Recommendation: In-memory with \"Remember me\" extending cookie life\n\n2. **Multiple failed login attempts**: Rate limiting?\n   - Recommendation: Simple delay after 5 failed attempts\n\n3. **Password requirements**: Minimum complexity?\n   - Recommendation: Minimum 8 characters, no complexity rules (user's choice)\n\n4. **HTTPS requirement**: Should auth require HTTPS?\n   - Recommendation: Warn but allow HTTP (self-hosted often behind reverse proxy)\n\n---\n\n## Risk Assessment\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|------------|--------|------------|\n| User locked out | Medium | High | Env var override, clear docs |\n| Session hijacking | Low | Medium | Secure cookies, HTTPS warning |\n| Brute force attack | Low | Medium | Rate limiting after failures |\n| Regression in existing installs | Low | High | Comprehensive testing |\n| Complexity creep | Medium | Medium | Keep single-user, no roles |\n\n---\n\n## Success Criteria\n\n- [ ] Existing installations work unchanged after update\n- [ ] Auth can be enabled from Settings with zero config files\n- [ ] Login page is functional and styled consistently\n- [ ] Sessions persist across server restarts (Remember me)\n- [ ] Lockout recovery is documented and tested\n- [ ] API keys continue working independently\n- [ ] No performance impact when auth is disabled\n"
  },
  {
    "path": "README.md",
    "content": "# SubTrackr\n\nA self-hosted subscription management application built with Go and HTMX. Track your subscriptions, visualize spending, and get renewal reminders.\n\n![SubTrackr Dashboard](dashboard-screenshot.png)\n\n![SubTrackr Calendar View](calendar-screenshot.png)\n\n![SubTrackr Mobile View](mobile-screenshot.png)\n\n## 🎨 Themes\n\nPersonalize your SubTrackr experience with 5 beautiful themes:\n\n<table>\n  <tr>\n    <td align=\"center\">\n      <img src=\"screenshots/christmas.png\" alt=\"Christmas Theme\" width=\"600\"/><br/>\n      <b>Christmas 🎄</b><br/>\n      Festive and jolly! (with snowfall animation)\n    </td>\n  </tr>\n  <tr>\n    <td align=\"center\">\n      <img src=\"screenshots/ocean.png\" alt=\"Ocean Theme\" width=\"600\"/><br/>\n      <b>Ocean</b><br/>\n      Cool and refreshing\n    </td>\n  </tr>\n  <tr>\n    <td align=\"center\">\n      <img src=\"screenshots/login.png\" alt=\"Login Page\" width=\"600\"/><br/>\n      <b>Optional Authentication</b><br/>\n      Secure your data with optional login support\n    </td>\n  </tr>\n</table>\n\n**Available themes:** Default (Light), Dark, Christmas 🎄, Midnight (Purple), Ocean (Cyan)\n\nThemes persist across all pages and are saved per user. Change themes anytime from Settings → Appearance.\n\n![Version](https://img.shields.io/github/v/release/bscott/subtrackr?logo=github&label=version)\n![Go Version](https://img.shields.io/badge/go-%3E%3D1.21-00ADD8)\n![License](https://img.shields.io/badge/license-AGPL--3.0-green)\n\n## 🚀 Features\n\n- 📊 **Dashboard Overview**: Real-time stats showing monthly/annual spending\n- 💰 **Subscription Management**: Track all your subscriptions in one place with logos\n- 📅 **Calendar View**: Visual calendar showing all subscription renewal dates with iCal export and subscription URL\n- 📈 **Analytics**: Visualize spending by category and track savings\n- 🔔 **Email Notifications**: Get reminders before subscriptions renew\n- 📱 **Pushover Notifications**: Receive push notifications on your mobile device\n- 📤 **Data Export**: Export your data as CSV, JSON, or iCal format\n- 🎨 **Beautiful Themes**: 5 stunning themes including a festive Christmas theme with snowfall animation\n- 🌍 **Multi-Currency Support**: Support for USD, EUR, GBP, JPY, RUB, SEK, PLN, INR, CHF, BRL, COP, BDT, and CNY (with optional real-time conversion)\n- 🤖 **MCP Server**: AI integration via Model Context Protocol for Claude and other AI assistants\n- 🐳 **Docker Ready**: Easy deployment with Docker\n- 🔒 **Self-Hosted**: Your data stays on your server\n- 📱 **Mobile Responsive**: Optimized mobile experience with hamburger menu navigation\n\n## 🏗️ Tech Stack\n\n- **Backend**: Go with Gin framework\n- **Database**: SQLite (no external database needed!)\n- **Frontend**: HTMX + Tailwind CSS\n- **Deployment**: Docker & Docker Compose\n\n## 🚀 Quick Start\n\nSubTrackr is available as a multi-platform Docker image supporting both AMD64 and ARM64 architectures (including Apple Silicon).\n\n**Note:** SubTrackr works fully out-of-the-box with no external dependencies. The Fixer.io API key is completely optional for currency conversion features.\n\n### Option 1: Docker Compose (Recommended)\n\n1. **Create docker-compose.yml**:\n\n```yaml\nversion: '3.8'\n\nservices:\n  subtrackr:\n    image: ghcr.io/bscott/subtrackr:latest\n    container_name: subtrackr\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - ./data:/app/data\n    environment:\n      - GIN_MODE=release\n      - DATABASE_PATH=/app/data/subtrackr.db\n      # Optional: Enable automatic currency conversion (requires Fixer.io API key)\n      # - FIXER_API_KEY=your_fixer_api_key_here\n    restart: unless-stopped\n```\n\n2. **Start the container**:\n\n```bash\ndocker-compose up -d\n```\n\n3. **Access SubTrackr**: Open http://localhost:8080\n\n### Option 2: Docker Run\n\n```bash\ndocker run -d \\\n  --name subtrackr \\\n  -p 8080:8080 \\\n  -v $(pwd)/data:/app/data \\\n  -e GIN_MODE=release \\\n  ghcr.io/bscott/subtrackr:latest\n\n# Optional: With currency conversion enabled\ndocker run -d \\\n  --name subtrackr \\\n  -p 8080:8080 \\\n  -v $(pwd)/data:/app/data \\\n  -e GIN_MODE=release \\\n  -e FIXER_API_KEY=your_fixer_api_key_here \\\n  ghcr.io/bscott/subtrackr:latest\n```\n\n### Option 3: Build from Source\n\n1. **Clone the repository**:\n```bash\ngit clone https://github.com/bscott/subtrackr.git\ncd subtrackr\n```\n\n2. **Build and run with Docker Compose**:\n```bash\ndocker-compose up -d --build\n```\n\n## 🐳 Deployment Guides\n\n### Portainer\n\n1. **Stack Deployment**:\n   - Go to Stacks → Add Stack\n   - Name: `subtrackr`\n   - Paste the docker-compose.yml content\n   - Deploy the stack\n\n2. **Environment Variables** (optional):\n   ```\n   PORT=8080\n   DATABASE_PATH=/app/data/subtrackr.db\n   GIN_MODE=release\n   ```\n\n3. **Volumes**:\n   - Create a volume named `subtrackr-data`\n   - Mount to `/app/data` in the container\n\n### Proxmox LXC Container\n\n1. **Create LXC Container**:\n   ```bash\n   # Create container (Ubuntu 22.04)\n   pct create 200 local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.gz \\\n     --hostname subtrackr \\\n     --memory 512 \\\n     --cores 1 \\\n     --net0 name=eth0,bridge=vmbr0,ip=dhcp \\\n     --storage local-lvm \\\n     --rootfs local-lvm:8\n   ```\n\n2. **Install Docker in LXC**:\n   ```bash\n   pct start 200\n   pct enter 200\n   \n   # Update and install Docker\n   apt update && apt upgrade -y\n   curl -fsSL https://get.docker.com | sh\n   ```\n\n3. **Deploy SubTrackr**:\n   ```bash\n   mkdir -p /opt/subtrackr\n   cd /opt/subtrackr\n   \n   # Create docker-compose.yml\n   nano docker-compose.yml\n   # Paste the docker-compose content\n   \n   docker-compose up -d\n   ```\n\n### Unraid\n\n1. **Community Applications**:\n   - Search for \"SubTrackr\" in CA\n   - Configure paths and ports\n   - Apply\n\n2. **Manual Docker Template**:\n   - Repository: `ghcr.io/bscott/subtrackr:latest`\n   - Port: `8080:8080`\n   - Path: `/app/data` → `/mnt/user/appdata/subtrackr`\n\n### Synology NAS\n\n1. **Using Docker Package**:\n   - Open Docker package\n   - Registry → Search \"subtrackr\"\n   - Download latest image\n   - Create container with port 8080 and volume mapping\n\n2. **Using Container Manager** (DSM 7.2+):\n   - Project → Create\n   - Upload docker-compose.yml\n   - Build and run\n\n## 🔧 Configuration\n\n### Environment Variables\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `PORT` | Server port | `8080` |\n| `DATABASE_PATH` | SQLite database file path | `./data/subtrackr.db` |\n| `GIN_MODE` | Gin framework mode (debug/release) | `debug` |\n| `FIXER_API_KEY` | Fixer.io API key for currency conversion (optional) | None |\n\n### Currency Conversion (Optional)\n\nSubTrackr supports automatic currency conversion using Fixer.io exchange rates:\n\n**Without API key:** (Fully functional)\n- Basic multi-currency support with display symbols\n- Manual currency selection per subscription\n- Subscriptions displayed in their original currency\n- No automatic conversion between currencies\n\n**With Fixer.io API key:**\n- Real-time exchange rates (cached for 24 hours)\n- Automatic conversion between any supported currencies\n- Display original amount + converted amount in your preferred currency\n\n**Setup:**\n1. Sign up for free at [Fixer.io](https://fixer.io/) (1000 requests/month)\n2. Get your API key from the dashboard\n3. Add `FIXER_API_KEY=your_key_here` to your environment variables\n4. Restart SubTrackr - currency conversion will be automatically enabled\n\n**Note:** The free Fixer.io plan only allows EUR as the base currency. SubTrackr automatically handles cross-rate calculations (e.g., USD→INR goes through EUR) so all currency conversions work correctly regardless of this limitation.\n\n**Supported currencies:** USD, EUR, GBP, JPY, RUB, SEK, PLN, INR, CHF, BRL, COP, BDT, CNY\n\n### Email Notifications (SMTP)\n\nConfigure SMTP settings in the web interface:\n\n1. Navigate to Settings → Email Notifications\n2. Enter your SMTP details:\n   - **Gmail**: smtp.gmail.com:587\n   - **Outlook**: smtp-mail.outlook.com:587\n   - **Custom**: Your SMTP server details\n3. Test connection\n4. Enable renewal reminders\n\n### Pushover Notifications\n\nReceive push notifications on your mobile device via Pushover:\n\n1. **Get your Pushover credentials**:\n   - Sign up at [pushover.net](https://pushover.net/) (free account)\n   - Get your User Key from the dashboard\n   - Create an application at [pushover.net/apps/build](https://pushover.net/apps/build) to get an Application Token\n\n2. **Configure in SubTrackr**:\n   - Navigate to Settings → Pushover Notifications\n   - Enter your User Key and Application Token\n   - Click \"Test Connection\" to verify configuration\n   - Save settings\n\n3. **Notification Types**:\n   - **Renewal Reminders**: Get notified before subscriptions renew (uses the same reminder days setting as email)\n   - **High Cost Alerts**: Receive alerts when adding expensive subscriptions (uses the same threshold as email alerts)\n\n**Note**: Pushover notifications work alongside email notifications. Both will be sent when enabled, giving you multiple ways to stay informed about your subscriptions.\n\n### Data Persistence\n\n**Important**: Always mount a volume to `/app/data` to persist your database!\n\n```yaml\nvolumes:\n  - ./data:/app/data  # Local directory\n  # OR\n  - subtrackr-data:/app/data  # Named volume\n```\n\n## 🔐 Security Recommendations\n\n1. **Reverse Proxy**: Use Nginx/Traefik for HTTPS\n2. **Authentication**: Add basic auth or OAuth2 proxy\n3. **Network**: Don't expose port 8080 directly to internet\n4. **Backups**: Regular backups of the data directory\n\n### Nginx Reverse Proxy Example\n\n```nginx\nserver {\n    server_name subtrackr.yourdomain.com;\n    \n    location / {\n        proxy_pass http://localhost:8080;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n```\n\n### Traefik Labels\n\n```yaml\nlabels:\n  - \"traefik.enable=true\"\n  - \"traefik.http.routers.subtrackr.rule=Host(`subtrackr.yourdomain.com`)\"\n  - \"traefik.http.routers.subtrackr.entrypoints=websecure\"\n  - \"traefik.http.routers.subtrackr.tls.certresolver=letsencrypt\"\n```\n\n## 📊 API Documentation\n\nSubTrackr provides a RESTful API for external integrations. All API endpoints require authentication using an API key.\n\n### Authentication\n\nCreate an API key from the Settings page in the web interface. Include the API key in your requests using one of these methods:\n\n```bash\n# Authorization header (recommended)\ncurl -H \"Authorization: Bearer sk_your_api_key_here\" https://your-domain.com/api/v1/subscriptions\n\n# X-API-Key header\ncurl -H \"X-API-Key: sk_your_api_key_here\" https://your-domain.com/api/v1/subscriptions\n```\n\n### API Endpoints\n\n#### Subscriptions\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/api/v1/subscriptions` | List all subscriptions |\n| POST | `/api/v1/subscriptions` | Create a new subscription |\n| GET | `/api/v1/subscriptions/:id` | Get subscription details |\n| PUT | `/api/v1/subscriptions/:id` | Update subscription |\n| DELETE | `/api/v1/subscriptions/:id` | Delete subscription |\n\n#### Statistics & Export\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/api/v1/stats` | Get subscription statistics |\n| GET | `/api/v1/export/csv` | Export subscriptions as CSV |\n| GET | `/api/v1/export/json` | Export subscriptions as JSON |\n\n### Example Requests\n\n#### List Subscriptions\n```bash\ncurl -H \"Authorization: Bearer sk_your_api_key_here\" \\\n  https://your-domain.com/api/v1/subscriptions\n```\n\n#### Create Subscription\n```bash\ncurl -X POST \\\n  -H \"Authorization: Bearer sk_your_api_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"Netflix\",\n    \"cost\": 15.99,\n    \"schedule\": \"Monthly\",\n    \"status\": \"Active\",\n    \"category\": \"Entertainment\"\n  }' \\\n  https://your-domain.com/api/v1/subscriptions\n```\n\n#### Update Subscription\n```bash\ncurl -X PUT \\\n  -H \"Authorization: Bearer sk_your_api_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"cost\": 17.99,\n    \"status\": \"Active\"\n  }' \\\n  https://your-domain.com/api/v1/subscriptions/123\n```\n\n#### Get Statistics\n```bash\ncurl -H \"Authorization: Bearer sk_your_api_key_here\" \\\n  https://your-domain.com/api/v1/stats\n```\n\nResponse:\n```json\n{\n  \"total_count\": 15,\n  \"active_count\": 12,\n  \"total_cost\": 245.67,\n  \"categories\": {\n    \"Entertainment\": 45.99,\n    \"Productivity\": 89.00,\n    \"Storage\": 29.99\n  }\n}\n```\n\n## 🤖 MCP Server (AI Integration)\n\nSubTrackr includes a [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that allows AI assistants like Claude to read and manage your subscriptions via natural language.\n\n### Available Tools\n\n| Tool | Description |\n|------|-------------|\n| `list_subscriptions` | List all subscriptions |\n| `get_subscription` | Get a subscription by ID |\n| `create_subscription` | Create a new subscription |\n| `update_subscription` | Update an existing subscription |\n| `delete_subscription` | Delete a subscription |\n| `get_stats` | Get subscription statistics |\n\n### Setup\n\n#### Local Install\n\nBuild the MCP server binary:\n\n```bash\ngo build -o subtrackr-mcp ./cmd/mcp\n```\n\nAdd to your Claude Desktop (`claude_desktop_config.json`) or Claude Code (`.claude/settings.json`):\n\n```json\n{\n  \"mcpServers\": {\n    \"subtrackr\": {\n      \"command\": \"/path/to/subtrackr-mcp\",\n      \"env\": {\n        \"DATABASE_PATH\": \"/path/to/subtrackr.db\"\n      }\n    }\n  }\n}\n```\n\n#### Docker\n\nThe MCP binary is included in the Docker image. Configure your MCP client to exec into the container:\n\n```json\n{\n  \"mcpServers\": {\n    \"subtrackr\": {\n      \"command\": \"docker\",\n      \"args\": [\"exec\", \"-i\", \"subtrackr\", \"/app/subtrackr-mcp\"]\n    }\n  }\n}\n```\n\nThe MCP server shares the same SQLite database as the web server, so changes made through either interface are immediately visible in the other.\n\n## 🛠️ Development\n\n### Prerequisites\n\n- Go 1.21+\n- Docker (optional)\n\n### Local Development\n\n```bash\n# Install dependencies\ngo mod download\n\n# Run development server\ngo run cmd/server/main.go\n\n# Build binary\ngo build -o subtrackr cmd/server/main.go\n```\n\n### Building Docker Image\n\n```bash\n# Build for current platform\ndocker build -t subtrackr:latest .\n\n# Build multi-platform\ndocker buildx build --platform linux/amd64,linux/arm64 \\\n  -t subtrackr:latest --push .\n```\n\n## 🤝 Contributing\n\nContributions are welcome! Please feel free to submit a Pull Request.\n\n1. Fork the repository\n2. Create your feature branch (`git checkout -b feature/AmazingFeature`)\n3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)\n4. Push to the branch (`git push origin feature/AmazingFeature`)\n5. Open a Pull Request\n\n## 📝 License\n\nThis project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) - see the [LICENSE](LICENSE) file for details.\n\n## 🙏 Acknowledgments\n\n- Built with [Gin](https://gin-gonic.com/) web framework\n- UI powered by [HTMX](https://htmx.org/) and [Tailwind CSS](https://tailwindcss.com/)\n- Icons from [Heroicons](https://heroicons.com/)\n\n## ⚠️ Known Limitations\n\n- **Price History**: SubTrackr currently tracks only the current price per subscription. If a subscription changes price over time, annual spend calculations will be based on the current price multiplied by the billing cycle, which may not reflect actual historical spending. For accurate historical tracking, consider manually updating subscription costs when prices change or keeping external records.\n\n## 📞 Support\n\n- 🐛 Issues: [GitHub Issues](https://github.com/bscott/subtrackr/issues)\n- 💬 Discussions: [GitHub Discussions](https://github.com/bscott/subtrackr/discussions)\n\n---\n\nMade with ❤️ by me and Vibing "
  },
  {
    "path": "cmd/mcp/main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"strconv\"\n\t\"subtrackr/internal/config\"\n\t\"subtrackr/internal/database\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"subtrackr/internal/service\"\n\t\"subtrackr/internal/version\"\n\t\"time\"\n\n\t\"github.com/modelcontextprotocol/go-sdk/mcp\"\n)\n\nfunc main() {\n\tcfg := config.Load()\n\n\tdb, err := database.Initialize(cfg.DatabasePath)\n\tif err != nil {\n\t\tlog.Fatal(\"Failed to initialize database:\", err)\n\t}\n\n\tif err := database.RunMigrations(db); err != nil {\n\t\tlog.Fatal(\"Failed to run migrations:\", err)\n\t}\n\n\tsubscriptionRepo := repository.NewSubscriptionRepository(db)\n\tcategoryRepo := repository.NewCategoryRepository(db)\n\tcategoryService := service.NewCategoryService(categoryRepo)\n\tsubscriptionService := service.NewSubscriptionService(subscriptionRepo, categoryService)\n\n\tserver := mcp.NewServer(\n\t\t&mcp.Implementation{Name: \"subtrackr\", Version: version.GetVersion()},\n\t\tnil,\n\t)\n\n\t// list_subscriptions\n\ttype ListInput struct{}\n\ttype ListOutput struct {\n\t\tSubscriptions []models.Subscription `json:\"subscriptions\"`\n\t\tCount         int                   `json:\"count\"`\n\t}\n\tmcp.AddTool(server, &mcp.Tool{\n\t\tName:        \"list_subscriptions\",\n\t\tDescription: \"List all subscriptions\",\n\t}, func(ctx context.Context, req *mcp.CallToolRequest, input ListInput) (*mcp.CallToolResult, ListOutput, error) {\n\t\tsubs, err := subscriptionService.GetAll()\n\t\tif err != nil {\n\t\t\treturn nil, ListOutput{}, err\n\t\t}\n\t\treturn nil, ListOutput{Subscriptions: subs, Count: len(subs)}, nil\n\t})\n\n\t// get_subscription\n\ttype GetInput struct {\n\t\tID uint `json:\"id\" jsonschema:\"required,the subscription ID to retrieve\"`\n\t}\n\tmcp.AddTool(server, &mcp.Tool{\n\t\tName:        \"get_subscription\",\n\t\tDescription: \"Get a subscription by ID\",\n\t}, func(ctx context.Context, req *mcp.CallToolRequest, input GetInput) (*mcp.CallToolResult, *models.Subscription, error) {\n\t\tsub, err := subscriptionService.GetByID(input.ID)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"subscription not found: %w\", err)\n\t\t}\n\t\treturn nil, sub, nil\n\t})\n\n\t// create_subscription\n\ttype CreateInput struct {\n\t\tName             string `json:\"name\" jsonschema:\"required,the subscription name\"`\n\t\tCost             float64 `json:\"cost\" jsonschema:\"required,the subscription cost\"`\n\t\tSchedule         string `json:\"schedule\" jsonschema:\"required,billing schedule: Monthly, Annual, Weekly, Daily, or Quarterly\"`\n\t\tStatus           string `json:\"status\" jsonschema:\"subscription status: Active, Cancelled, Paused, or Trial\"`\n\t\tOriginalCurrency string `json:\"original_currency\" jsonschema:\"currency code e.g. USD, EUR\"`\n\t\tPaymentMethod    string `json:\"payment_method\" jsonschema:\"payment method\"`\n\t\tAccount          string `json:\"account\" jsonschema:\"account identifier\"`\n\t\tURL              string `json:\"url\" jsonschema:\"subscription URL\"`\n\t\tNotes            string `json:\"notes\" jsonschema:\"additional notes\"`\n\t\tStartDate        string `json:\"start_date\" jsonschema:\"start date in YYYY-MM-DD format\"`\n\t\tRenewalDate      string `json:\"renewal_date\" jsonschema:\"renewal date in YYYY-MM-DD format\"`\n\t\tCategoryID       uint   `json:\"category_id\" jsonschema:\"category ID\"`\n\t}\n\tmcp.AddTool(server, &mcp.Tool{\n\t\tName:        \"create_subscription\",\n\t\tDescription: \"Create a new subscription\",\n\t}, func(ctx context.Context, req *mcp.CallToolRequest, input CreateInput) (*mcp.CallToolResult, *models.Subscription, error) {\n\t\tsub := &models.Subscription{\n\t\t\tName:             input.Name,\n\t\t\tCost:             input.Cost,\n\t\t\tSchedule:         input.Schedule,\n\t\t\tStatus:           input.Status,\n\t\t\tOriginalCurrency: input.OriginalCurrency,\n\t\t\tPaymentMethod:    input.PaymentMethod,\n\t\t\tAccount:          input.Account,\n\t\t\tURL:              input.URL,\n\t\t\tNotes:            input.Notes,\n\t\t\tCategoryID:       input.CategoryID,\n\t\t}\n\t\tif sub.Status == \"\" {\n\t\t\tsub.Status = \"Active\"\n\t\t}\n\t\tif sub.OriginalCurrency == \"\" {\n\t\t\tsub.OriginalCurrency = \"USD\"\n\t\t}\n\t\tif input.StartDate != \"\" {\n\t\t\tif t, err := time.Parse(\"2006-01-02\", input.StartDate); err == nil {\n\t\t\t\tsub.StartDate = &t\n\t\t\t}\n\t\t}\n\t\tif input.RenewalDate != \"\" {\n\t\t\tif t, err := time.Parse(\"2006-01-02\", input.RenewalDate); err == nil {\n\t\t\t\tsub.RenewalDate = &t\n\t\t\t}\n\t\t}\n\t\tcreated, err := subscriptionService.Create(sub)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to create subscription: %w\", err)\n\t\t}\n\t\treturn nil, created, nil\n\t})\n\n\t// update_subscription\n\ttype UpdateInput struct {\n\t\tID               uint    `json:\"id\" jsonschema:\"required,the subscription ID to update\"`\n\t\tName             string  `json:\"name\" jsonschema:\"new name\"`\n\t\tCost             float64 `json:\"cost\" jsonschema:\"new cost\"`\n\t\tSchedule         string  `json:\"schedule\" jsonschema:\"new schedule: Monthly, Annual, Weekly, Daily, or Quarterly\"`\n\t\tStatus           string  `json:\"status\" jsonschema:\"new status: Active, Cancelled, Paused, or Trial\"`\n\t\tOriginalCurrency string  `json:\"original_currency\" jsonschema:\"new currency code\"`\n\t\tPaymentMethod    string  `json:\"payment_method\" jsonschema:\"new payment method\"`\n\t\tAccount          string  `json:\"account\" jsonschema:\"new account\"`\n\t\tURL              string  `json:\"url\" jsonschema:\"new URL\"`\n\t\tNotes            string  `json:\"notes\" jsonschema:\"new notes\"`\n\t\tStartDate        string  `json:\"start_date\" jsonschema:\"new start date in YYYY-MM-DD format\"`\n\t\tRenewalDate      string  `json:\"renewal_date\" jsonschema:\"new renewal date in YYYY-MM-DD format\"`\n\t\tCategoryID       uint    `json:\"category_id\" jsonschema:\"new category ID\"`\n\t}\n\tmcp.AddTool(server, &mcp.Tool{\n\t\tName:        \"update_subscription\",\n\t\tDescription: \"Update an existing subscription\",\n\t}, func(ctx context.Context, req *mcp.CallToolRequest, input UpdateInput) (*mcp.CallToolResult, *models.Subscription, error) {\n\t\t// Get existing subscription to merge fields\n\t\texisting, err := subscriptionService.GetByID(input.ID)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"subscription not found: %w\", err)\n\t\t}\n\n\t\t// Detect which fields were explicitly provided via raw JSON\n\t\tvar provided map[string]json.RawMessage\n\t\tjson.Unmarshal(req.Params.Arguments, &provided)\n\n\t\tif _, ok := provided[\"name\"]; ok {\n\t\t\texisting.Name = input.Name\n\t\t}\n\t\tif _, ok := provided[\"cost\"]; ok {\n\t\t\texisting.Cost = input.Cost\n\t\t}\n\t\tif _, ok := provided[\"schedule\"]; ok {\n\t\t\texisting.Schedule = input.Schedule\n\t\t}\n\t\tif _, ok := provided[\"status\"]; ok {\n\t\t\texisting.Status = input.Status\n\t\t}\n\t\tif _, ok := provided[\"original_currency\"]; ok {\n\t\t\texisting.OriginalCurrency = input.OriginalCurrency\n\t\t}\n\t\tif _, ok := provided[\"payment_method\"]; ok {\n\t\t\texisting.PaymentMethod = input.PaymentMethod\n\t\t}\n\t\tif _, ok := provided[\"account\"]; ok {\n\t\t\texisting.Account = input.Account\n\t\t}\n\t\tif _, ok := provided[\"url\"]; ok {\n\t\t\texisting.URL = input.URL\n\t\t}\n\t\tif _, ok := provided[\"notes\"]; ok {\n\t\t\texisting.Notes = input.Notes\n\t\t}\n\t\tif _, ok := provided[\"category_id\"]; ok {\n\t\t\texisting.CategoryID = input.CategoryID\n\t\t}\n\t\tif _, ok := provided[\"start_date\"]; ok && input.StartDate != \"\" {\n\t\t\tif t, err := time.Parse(\"2006-01-02\", input.StartDate); err == nil {\n\t\t\t\texisting.StartDate = &t\n\t\t\t}\n\t\t}\n\t\tif _, ok := provided[\"renewal_date\"]; ok && input.RenewalDate != \"\" {\n\t\t\tif t, err := time.Parse(\"2006-01-02\", input.RenewalDate); err == nil {\n\t\t\t\texisting.RenewalDate = &t\n\t\t\t}\n\t\t}\n\n\t\tupdated, err := subscriptionService.Update(input.ID, existing)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to update subscription: %w\", err)\n\t\t}\n\t\treturn nil, updated, nil\n\t})\n\n\t// delete_subscription\n\ttype DeleteInput struct {\n\t\tID uint `json:\"id\" jsonschema:\"required,the subscription ID to delete\"`\n\t}\n\ttype DeleteOutput struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tmcp.AddTool(server, &mcp.Tool{\n\t\tName:        \"delete_subscription\",\n\t\tDescription: \"Delete a subscription by ID\",\n\t}, func(ctx context.Context, req *mcp.CallToolRequest, input DeleteInput) (*mcp.CallToolResult, DeleteOutput, error) {\n\t\tif err := subscriptionService.Delete(input.ID); err != nil {\n\t\t\treturn nil, DeleteOutput{}, fmt.Errorf(\"failed to delete subscription: %w\", err)\n\t\t}\n\t\treturn nil, DeleteOutput{Message: \"Subscription \" + strconv.Itoa(int(input.ID)) + \" deleted\"}, nil\n\t})\n\n\t// get_stats\n\ttype StatsInput struct{}\n\tmcp.AddTool(server, &mcp.Tool{\n\t\tName:        \"get_stats\",\n\t\tDescription: \"Get subscription statistics including total spending, counts, and category breakdown\",\n\t}, func(ctx context.Context, req *mcp.CallToolRequest, input StatsInput) (*mcp.CallToolResult, *models.Stats, error) {\n\t\tstats, err := subscriptionService.GetStats()\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to get stats: %w\", err)\n\t\t}\n\t\treturn nil, stats, nil\n\t})\n\n\tif err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "cmd/migrate-dates/main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n\t\"subtrackr/internal/database\"\n\t\"subtrackr/internal/models\"\n\t\"time\"\n\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc main() {\n\tvar (\n\t\tdbPath    = flag.String(\"db\", \"subtrackr.db\", \"Path to SQLite database\")\n\t\tdryRun    = flag.Bool(\"dry-run\", false, \"Show what would be changed without making changes\")\n\t\taction    = flag.String(\"action\", \"compare\", \"Action to perform: compare, migrate, rollback, stats\")\n\t\tsubID     = flag.Uint(\"subscription-id\", 0, \"Subscription ID for single operations\")\n\t\treason    = flag.String(\"reason\", \"Manual migration\", \"Reason for migration\")\n\t)\n\tflag.Parse()\n\n\t// Open database\n\tdb, err := gorm.Open(sqlite.Open(*dbPath), &gorm.Config{})\n\tif err != nil {\n\t\tlog.Fatal(\"Failed to connect to database:\", err)\n\t}\n\n\t// Run migrations to ensure schema is up to date\n\tif err := database.RunMigrations(db); err != nil {\n\t\tlog.Fatal(\"Failed to run migrations:\", err)\n\t}\n\n\t// Auto-migrate audit log table\n\tif err := db.AutoMigrate(&models.DateMigrationLog{}); err != nil {\n\t\tlog.Fatal(\"Failed to migrate audit log table:\", err)\n\t}\n\n\t// Create migration safety checker\n\tchecker := models.NewDateMigrationSafetyCheck(db)\n\n\tswitch *action {\n\tcase \"compare\":\n\t\tif *subID == 0 {\n\t\t\tfmt.Println(\"Comparing all subscriptions V1 vs V2...\")\n\t\t\tcompareAllSubscriptions(db)\n\t\t} else {\n\t\t\tcompareSubscription(checker, *subID)\n\t\t}\n\n\tcase \"migrate\":\n\t\tif *subID == 0 {\n\t\t\tfmt.Printf(\"Migrating all subscriptions to V2 (dry-run: %v)...\\n\", *dryRun)\n\t\t\tif err := checker.BatchMigrateToV2WithAudit(*dryRun); err != nil {\n\t\t\t\tlog.Fatal(\"Migration failed:\", err)\n\t\t\t}\n\t\t\tfmt.Println(\"Migration completed successfully\")\n\t\t} else {\n\t\t\tfmt.Printf(\"Migrating subscription %d to V2...\\n\", *subID)\n\t\t\tif err := checker.MigrateSubscriptionToV2(*subID, *reason); err != nil {\n\t\t\t\tlog.Fatal(\"Migration failed:\", err)\n\t\t\t}\n\t\t\tfmt.Println(\"Subscription migrated successfully\")\n\t\t}\n\n\tcase \"rollback\":\n\t\tif *subID == 0 {\n\t\t\tfmt.Println(\"Batch rollback not supported for safety. Use --subscription-id\")\n\t\t\tos.Exit(1)\n\t\t}\n\t\tfmt.Printf(\"Rolling back subscription %d to V1...\\n\", *subID)\n\t\tif err := checker.RollbackSubscriptionToV1(*subID, *reason); err != nil {\n\t\t\tlog.Fatal(\"Rollback failed:\", err)\n\t\t}\n\t\tfmt.Println(\"Subscription rolled back successfully\")\n\n\tcase \"stats\":\n\t\tstats, err := checker.GetMigrationStats()\n\t\tif err != nil {\n\t\t\tlog.Fatal(\"Failed to get stats:\", err)\n\t\t}\n\t\tprintStats(stats)\n\n\tdefault:\n\t\tfmt.Printf(\"Unknown action: %s\\n\", *action)\n\t\tfmt.Println(\"Valid actions: compare, migrate, rollback, stats\")\n\t\tos.Exit(1)\n\t}\n}\n\nfunc compareAllSubscriptions(db *gorm.DB) {\n\tvar subscriptions []models.Subscription\n\tdb.Find(&subscriptions)\n\n\tchecker := models.NewDateMigrationSafetyCheck(db)\n\n\tfmt.Printf(\"%-5s %-20s %-12s %-20s %-20s %-10s\\n\",\n\t\t\"ID\", \"Name\", \"Schedule\", \"V1 Date\", \"V2 Date\", \"Diff (days)\")\n\tfmt.Println(strings.Repeat(\"-\", 90))\n\n\tfor _, sub := range subscriptions {\n\t\tv1Date, v2Date, err := checker.CompareCalculationVersions(sub.ID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tv1Str := \"nil\"\n\t\tv2Str := \"nil\"\n\t\tdiffStr := \"N/A\"\n\n\t\tif v1Date != nil {\n\t\t\tv1Str = v1Date.Format(\"2006-01-02\")\n\t\t}\n\t\tif v2Date != nil {\n\t\t\tv2Str = v2Date.Format(\"2006-01-02\")\n\t\t}\n\t\tif v1Date != nil && v2Date != nil {\n\t\t\tdiff := v2Date.Sub(*v1Date).Truncate(24*time.Hour).Hours() / 24\n\t\t\tdiffStr = fmt.Sprintf(\"%.1f\", diff)\n\t\t}\n\n\t\tname := sub.Name\n\t\tif len(name) > 18 {\n\t\t\tname = name[:15] + \"...\"\n\t\t}\n\n\t\tfmt.Printf(\"%-5d %-20s %-12s %-20s %-20s %-10s\\n\",\n\t\t\tsub.ID, name, sub.Schedule, v1Str, v2Str, diffStr)\n\t}\n}\n\nfunc compareSubscription(checker *models.DateMigrationSafetyCheck, id uint) {\n\tv1Date, v2Date, err := checker.CompareCalculationVersions(id)\n\tif err != nil {\n\t\tlog.Fatal(\"Failed to compare:\", err)\n\t}\n\n\tfmt.Printf(\"Subscription %d comparison:\\n\", id)\n\tif v1Date != nil {\n\t\tfmt.Printf(\"V1 Date: %s\\n\", v1Date.Format(\"2006-01-02 15:04:05\"))\n\t} else {\n\t\tfmt.Println(\"V1 Date: nil\")\n\t}\n\tif v2Date != nil {\n\t\tfmt.Printf(\"V2 Date: %s\\n\", v2Date.Format(\"2006-01-02 15:04:05\"))\n\t} else {\n\t\tfmt.Println(\"V2 Date: nil\")\n\t}\n\n\tif v1Date != nil && v2Date != nil {\n\t\tdiff := v2Date.Sub(*v1Date).Truncate(24*time.Hour).Hours() / 24\n\t\tfmt.Printf(\"Difference: %.1f days\\n\", diff)\n\t}\n}\n\nfunc printStats(stats map[string]interface{}) {\n\tfmt.Println(\"Date Calculation Migration Statistics:\")\n\tfmt.Println(\"=====================================\")\n\tfmt.Printf(\"V1 Subscriptions: %v\\n\", stats[\"v1_subscriptions\"])\n\tfmt.Printf(\"V2 Subscriptions: %v\\n\", stats[\"v2_subscriptions\"])\n\tfmt.Printf(\"Total Migrations: %v\\n\", stats[\"total_migrations\"])\n\tfmt.Printf(\"Rollbacks: %v\\n\", stats[\"rollbacks\"])\n}"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '3.8'\n\nservices:\n  subtrackr:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - ./data:/app/data\n      - ./web:/app/web\n      - ./templates:/app/templates\n    environment:\n      - GIN_MODE=release\n      - DATABASE_PATH=/app/data/subtrackr.db\n      - PORT=8080\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--quiet\", \"--tries=1\", \"--spider\", \"http://localhost:8080/health\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 40s\n\nvolumes:\n  subtrackr_data:\n    driver: local"
  },
  {
    "path": "go.mod",
    "content": "module subtrackr\n\ngo 1.24.0\n\nrequire (\n\tgithub.com/dromara/carbon/v2 v2.6.11\n\tgithub.com/gin-gonic/gin v1.9.1\n\tgithub.com/gorilla/sessions v1.4.0\n\tgithub.com/stretchr/testify v1.11.1\n\tgolang.org/x/crypto v0.46.0\n\tgolang.org/x/term v0.38.0\n\tgorm.io/driver/sqlite v1.5.4\n\tgorm.io/gorm v1.25.5\n)\n\nrequire (\n\tgithub.com/bytedance/sonic v1.9.1 // indirect\n\tgithub.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.2 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.14.0 // indirect\n\tgithub.com/goccy/go-json v0.10.2 // indirect\n\tgithub.com/google/jsonschema-go v0.4.2 // indirect\n\tgithub.com/gorilla/securecookie v1.1.2 // indirect\n\tgithub.com/jinzhu/inflection v1.0.0 // indirect\n\tgithub.com/jinzhu/now v1.1.5 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.4 // indirect\n\tgithub.com/leodido/go-urn v1.2.4 // indirect\n\tgithub.com/mattn/go-isatty v0.0.19 // indirect\n\tgithub.com/mattn/go-sqlite3 v1.14.17 // indirect\n\tgithub.com/modelcontextprotocol/go-sdk v1.3.0 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.0.8 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.11 // indirect\n\tgithub.com/yosida95/uritemplate/v3 v3.0.2 // indirect\n\tgolang.org/x/arch v0.3.0 // indirect\n\tgolang.org/x/net v0.47.0 // indirect\n\tgolang.org/x/oauth2 v0.30.0 // indirect\n\tgolang.org/x/sys v0.39.0 // indirect\n\tgolang.org/x/text v0.32.0 // indirect\n\tgoogle.golang.org/protobuf v1.30.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=\ngithub.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=\ngithub.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=\ngithub.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=\ngithub.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=\ngithub.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dromara/carbon/v2 v2.6.11 h1:wnAWZ+sbza1uXw3r05hExNSCaBPFaarWfUvYAX86png=\ngithub.com/dromara/carbon/v2 v2.6.11/go.mod h1:7GXqCUplwN1s1b4whGk2zX4+g4CMCoDIZzmjlyt0vLY=\ngithub.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=\ngithub.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\ngithub.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=\ngithub.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=\ngithub.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=\ngithub.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=\ngithub.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=\ngithub.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=\ngithub.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=\ngithub.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=\ngithub.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=\ngithub.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=\ngithub.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=\ngithub.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=\ngithub.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=\ngithub.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=\ngithub.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=\ngithub.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=\ngithub.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=\ngithub.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=\ngithub.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=\ngithub.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=\ngithub.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=\ngithub.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=\ngithub.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngithub.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=\ngithub.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=\ngolang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=\ngolang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=\ngolang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=\ngolang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=\ngolang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=\ngolang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=\ngolang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=\ngolang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=\ngolang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=\ngolang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=\ngolang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=\ngolang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=\ngoogle.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=\ngorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=\ngorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=\ngorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=\nrsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "package config\n\nimport (\n\t\"os\"\n)\n\ntype Config struct {\n\tDatabasePath string\n\tPort         string\n\tEnvironment  string\n}\n\nfunc Load() *Config {\n\treturn &Config{\n\t\tDatabasePath: getEnv(\"DATABASE_PATH\", \"./data/subtrackr.db\"),\n\t\tPort:         getEnv(\"PORT\", \"8080\"),\n\t\tEnvironment:  getEnv(\"GIN_MODE\", \"debug\"),\n\t}\n}\n\nfunc getEnv(key, defaultValue string) string {\n\tif value := os.Getenv(key); value != \"\" {\n\t\treturn value\n\t}\n\treturn defaultValue\n}"
  },
  {
    "path": "internal/database/database.go",
    "content": "package database\n\nimport (\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\nfunc Initialize(dbPath string) (*gorm.DB, error) {\n\tdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Enable foreign key constraints\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_, err = sqlDB.Exec(\"PRAGMA foreign_keys = ON\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn db, nil\n}"
  },
  {
    "path": "internal/database/migrations.go",
    "content": "package database\n\nimport (\n\t\"log\"\n\t\"subtrackr/internal/models\"\n\n\t\"gorm.io/gorm\"\n)\n\n// RunMigrations executes all database migrations\nfunc RunMigrations(db *gorm.DB) error {\n\t// Auto-migrate non-problematic models first\n\terr := db.AutoMigrate(&models.Category{}, &models.Settings{}, &models.APIKey{}, &models.ExchangeRate{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Run specific migrations\n\tmigrations := []func(*gorm.DB) error{\n\t\tmigrateCategoriesToDynamic,\n\t\tmigrateCurrencyFields,\n\t\tmigrateDateCalculationVersioning,\n\t\tmigrateSubscriptionIcons,\n\t\tmigrateReminderTracking,\n\t\tmigrateCancellationReminderTracking,\n\t\tmigrateScheduleInterval,\n\t\tmigrateReminderEnabled,\n\t}\n\n\tfor _, migration := range migrations {\n\t\tif err := migration(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Try to auto-migrate subscriptions after the category migration\n\t// This might fail on existing databases but that's okay\n\tdb.AutoMigrate(&models.Subscription{})\n\n\treturn nil\n}\n\n// migrateCategoriesToDynamic handles the v0.3.0 migration from string categories to category IDs\nfunc migrateCategoriesToDynamic(db *gorm.DB) error {\n\t// Check if migration is needed by looking for the old category column\n\tvar count int64\n\tdb.Raw(\"SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='category'\").Scan(&count)\n\n\tif count == 0 {\n\t\t// Migration already completed\n\t\treturn nil\n\t}\n\n\tlog.Println(\"Running migration: Converting categories to dynamic system...\")\n\n\t// First ensure default categories exist\n\tdefaultCategories := []string{\"Entertainment\", \"Productivity\", \"Storage\", \"Software\", \"Fitness\", \"Education\", \"Food\", \"Travel\", \"Business\", \"Other\"}\n\tvar categories []models.Category\n\tdb.Find(&categories)\n\n\tif len(categories) == 0 {\n\t\tfor _, name := range defaultCategories {\n\t\t\tdb.Create(&models.Category{Name: name})\n\t\t}\n\t\tdb.Find(&categories) // Reload categories\n\t}\n\n\t// Create category map\n\tcategoryMap := make(map[string]uint)\n\tfor _, cat := range categories {\n\t\tcategoryMap[cat.Name] = cat.ID\n\t}\n\n\t// Get all subscriptions that need migration\n\ttype OldSubscription struct {\n\t\tID       uint\n\t\tCategory string\n\t}\n\n\tvar oldSubs []OldSubscription\n\tdb.Table(\"subscriptions\").Select(\"id, category\").Scan(&oldSubs)\n\n\t// Update each subscription with the appropriate category_id\n\tfor _, sub := range oldSubs {\n\t\tif sub.Category != \"\" {\n\t\t\tif catID, exists := categoryMap[sub.Category]; exists {\n\t\t\t\tdb.Table(\"subscriptions\").Where(\"id = ?\", sub.ID).Update(\"category_id\", catID)\n\t\t\t} else {\n\t\t\t\t// If category doesn't exist, use \"Other\"\n\t\t\t\tif otherID, exists := categoryMap[\"Other\"]; exists {\n\t\t\t\t\tdb.Table(\"subscriptions\").Where(\"id = ?\", sub.ID).Update(\"category_id\", otherID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// SQLite limitation: we can't drop the old category column\n\t// The repository layer now handles both old and new schemas transparently\n\t// This ensures backward compatibility without data loss\n\n\tlog.Println(\"Migration completed: Categories converted to dynamic system\")\n\treturn nil\n}\n\n// migrateCurrencyFields adds original_currency field to existing subscriptions\nfunc migrateCurrencyFields(db *gorm.DB) error {\n\t// Check if original_currency column already exists\n\tvar count int64\n\tdb.Raw(\"SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='original_currency'\").Scan(&count)\n\n\tif count > 0 {\n\t\t// Migration already completed\n\t\treturn nil\n\t}\n\n\tlog.Println(\"Running migration: Adding currency fields...\")\n\n\t// Add original_currency column with default 'USD'\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN original_currency TEXT DEFAULT 'USD'\").Error; err != nil {\n\t\t// Column might already exist, that's okay\n\t\tlog.Printf(\"Note: Could not add original_currency column: %v\", err)\n\t}\n\n\t// Set USD as default for existing subscriptions\n\tif err := db.Exec(\"UPDATE subscriptions SET original_currency = 'USD' WHERE original_currency IS NULL OR original_currency = ''\").Error; err != nil {\n\t\tlog.Printf(\"Warning: Could not update existing subscriptions with default currency: %v\", err)\n\t}\n\n\tlog.Println(\"Migration completed: Currency fields added\")\n\treturn nil\n}\n\n// migrateDateCalculationVersioning adds date_calculation_version field for versioned date logic\nfunc migrateDateCalculationVersioning(db *gorm.DB) error {\n\t// Check if date_calculation_version column already exists\n\tvar count int64\n\tdb.Raw(\"SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='date_calculation_version'\").Scan(&count)\n\n\tif count > 0 {\n\t\t// Migration already completed\n\t\treturn nil\n\t}\n\n\tlog.Println(\"Running migration: Adding date calculation versioning...\")\n\n\t// Add date_calculation_version column with default 1 (existing logic)\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN date_calculation_version INTEGER DEFAULT 1\").Error; err != nil {\n\t\t// Column might already exist, that's okay\n\t\tlog.Printf(\"Note: Could not add date_calculation_version column: %v\", err)\n\t}\n\n\t// Set version 1 for all existing subscriptions (maintain backward compatibility)\n\tif err := db.Exec(\"UPDATE subscriptions SET date_calculation_version = 1 WHERE date_calculation_version IS NULL\").Error; err != nil {\n\t\tlog.Printf(\"Warning: Could not update existing subscriptions with default version: %v\", err)\n\t}\n\n\tlog.Println(\"Migration completed: Date calculation versioning added\")\n\treturn nil\n}\n\n// migrateSubscriptionIcons adds icon_url field to subscriptions table\nfunc migrateSubscriptionIcons(db *gorm.DB) error {\n\t// Check if icon_url column already exists\n\tvar count int64\n\tdb.Raw(\"SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='icon_url'\").Scan(&count)\n\n\tif count > 0 {\n\t\t// Migration already completed\n\t\treturn nil\n\t}\n\n\tlog.Println(\"Running migration: Adding subscription icon URLs...\")\n\n\t// Add icon_url column (nullable, empty string default)\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN icon_url TEXT DEFAULT ''\").Error; err != nil {\n\t\t// Column might already exist, that's okay\n\t\tlog.Printf(\"Note: Could not add icon_url column: %v\", err)\n\t}\n\n\t// Set empty string as default for existing subscriptions\n\tif err := db.Exec(\"UPDATE subscriptions SET icon_url = '' WHERE icon_url IS NULL\").Error; err != nil {\n\t\tlog.Printf(\"Warning: Could not update existing subscriptions with default icon_url: %v\", err)\n\t}\n\n\tlog.Println(\"Migration completed: Subscription icon URLs added\")\n\treturn nil\n}\n\n// migrateReminderTracking adds fields to track when reminders were sent\nfunc migrateReminderTracking(db *gorm.DB) error {\n\t// Check if last_reminder_sent column already exists\n\tvar count int64\n\tdb.Raw(\"SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='last_reminder_sent'\").Scan(&count)\n\n\tif count > 0 {\n\t\t// Migration already completed\n\t\treturn nil\n\t}\n\n\tlog.Println(\"Running migration: Adding reminder tracking fields...\")\n\n\t// Add last_reminder_sent column\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN last_reminder_sent DATETIME\").Error; err != nil {\n\t\tlog.Printf(\"Note: Could not add last_reminder_sent column: %v\", err)\n\t}\n\n\t// Add last_reminder_renewal_date column\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN last_reminder_renewal_date DATETIME\").Error; err != nil {\n\t\tlog.Printf(\"Note: Could not add last_reminder_renewal_date column: %v\", err)\n\t}\n\n\tlog.Println(\"Migration completed: Reminder tracking fields added\")\n\treturn nil\n}\n\n// migrateCancellationReminderTracking adds fields to track when cancellation reminders were sent\nfunc migrateCancellationReminderTracking(db *gorm.DB) error {\n\t// Check if last_cancellation_reminder_sent column already exists\n\tvar count int64\n\tdb.Raw(\"SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='last_cancellation_reminder_sent'\").Scan(&count)\n\n\tif count > 0 {\n\t\t// Migration already completed\n\t\treturn nil\n\t}\n\n\tlog.Println(\"Running migration: Adding cancellation reminder tracking fields...\")\n\n\t// Add last_cancellation_reminder_sent column\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN last_cancellation_reminder_sent DATETIME\").Error; err != nil {\n\t\tlog.Printf(\"Note: Could not add last_cancellation_reminder_sent column: %v\", err)\n\t}\n\n\t// Add last_cancellation_reminder_date column\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN last_cancellation_reminder_date DATETIME\").Error; err != nil {\n\t\tlog.Printf(\"Note: Could not add last_cancellation_reminder_date column: %v\", err)\n\t}\n\n\tlog.Println(\"Migration completed: Cancellation reminder tracking fields added\")\n\treturn nil\n}\n\nfunc migrateScheduleInterval(db *gorm.DB) error {\n\tvar count int64\n\tdb.Raw(\"SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='schedule_interval'\").Scan(&count)\n\n\tif count > 0 {\n\t\treturn nil\n\t}\n\n\tlog.Println(\"Running migration: Adding schedule interval field...\")\n\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN schedule_interval INTEGER DEFAULT 1\").Error; err != nil {\n\t\tlog.Printf(\"Note: Could not add schedule_interval column: %v\", err)\n\t}\n\n\tif err := db.Exec(\"UPDATE subscriptions SET schedule_interval = 1 WHERE schedule_interval IS NULL\").Error; err != nil {\n\t\tlog.Printf(\"Warning: Could not update existing subscriptions with default schedule_interval: %v\", err)\n\t}\n\n\tlog.Println(\"Migration completed: Schedule interval field added\")\n\treturn nil\n}\n\n// migrateReminderEnabled adds per-subscription reminder toggle field\nfunc migrateReminderEnabled(db *gorm.DB) error {\n\t// Check if column already exists\n\tvar count int64\n\tdb.Raw(\"SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name = 'reminder_enabled'\").Count(&count)\n\n\tif count > 0 {\n\t\treturn nil\n\t}\n\n\tlog.Println(\"Running migration: Adding per-subscription reminder_enabled field...\")\n\n\tif err := db.Exec(\"ALTER TABLE subscriptions ADD COLUMN reminder_enabled INTEGER DEFAULT 1\").Error; err != nil {\n\t\tlog.Printf(\"Note: Could not add reminder_enabled column: %v\", err)\n\t}\n\n\t// Set all existing subscriptions to enabled\n\tdb.Exec(\"UPDATE subscriptions SET reminder_enabled = 1 WHERE reminder_enabled IS NULL\")\n\n\tlog.Println(\"Migration completed: reminder_enabled field added\")\n\treturn nil\n}\n"
  },
  {
    "path": "internal/handlers/auth.go",
    "content": "package handlers\n\nimport (\n\t\"crypto/subtle\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"subtrackr/internal/service\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype AuthHandler struct {\n\tsettingsService *service.SettingsService\n\tsessionService  *service.SessionService\n\temailService    *service.EmailService\n}\n\nfunc NewAuthHandler(settingsService *service.SettingsService, sessionService *service.SessionService, emailService *service.EmailService) *AuthHandler {\n\treturn &AuthHandler{\n\t\tsettingsService: settingsService,\n\t\tsessionService:  sessionService,\n\t\temailService:    emailService,\n\t}\n}\n\n// isValidRedirect validates that a redirect URL is safe (relative URL only)\nfunc isValidRedirect(redirect string) bool {\n\t// Check URL length to prevent DoS or log injection\n\tif len(redirect) > 2048 {\n\t\treturn false\n\t}\n\n\t// Only allow relative URLs starting with / but not //\n\t// This prevents open redirect vulnerabilities\n\tif strings.HasPrefix(redirect, \"/\") && !strings.HasPrefix(redirect, \"//\") {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// ShowLoginPage displays the login page\nfunc (h *AuthHandler) ShowLoginPage(c *gin.Context) {\n\t// If already authenticated, redirect to dashboard\n\tif h.sessionService.IsAuthenticated(c.Request) {\n\t\tc.Redirect(http.StatusFound, \"/\")\n\t\treturn\n\t}\n\n\tredirect := c.Query(\"redirect\")\n\tif redirect == \"\" || !isValidRedirect(redirect) {\n\t\tredirect = \"/\"\n\t}\n\n\tc.HTML(http.StatusOK, \"login.html\", gin.H{\n\t\t\"Redirect\": redirect,\n\t\t\"Error\":    c.Query(\"error\"),\n\t})\n}\n\n// Login handles login form submission\nfunc (h *AuthHandler) Login(c *gin.Context) {\n\tusername := c.PostForm(\"username\")\n\tpassword := c.PostForm(\"password\")\n\trememberMe := c.PostForm(\"remember_me\") == \"on\"\n\tredirect := c.PostForm(\"redirect\")\n\n\tif redirect == \"\" || !isValidRedirect(redirect) {\n\t\tredirect = \"/\"\n\t}\n\n\t// Validate credentials using constant-time comparison to prevent timing attacks\n\tstoredUsername, err := h.settingsService.GetAuthUsername()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"login-error.html\", gin.H{\n\t\t\t\"Error\": \"Authentication system error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Always validate password even for invalid usernames (constant time)\n\tvalidUsername := subtle.ConstantTimeCompare([]byte(storedUsername), []byte(username)) == 1\n\n\tvar validPassword bool\n\tif err := h.settingsService.ValidatePassword(password); err == nil {\n\t\tvalidPassword = true\n\t}\n\n\t// Only fail after both checks to prevent username enumeration via timing\n\tif !validUsername || !validPassword {\n\t\tc.HTML(http.StatusUnauthorized, \"login-error.html\", gin.H{\n\t\t\t\"Error\": \"Invalid username or password\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Create session\n\tif err := h.sessionService.CreateSession(c.Writer, c.Request, rememberMe); err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"login-error.html\", gin.H{\n\t\t\t\"Error\": \"Failed to create session\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Redirect to original destination or dashboard\n\tc.Header(\"HX-Redirect\", redirect)\n\tc.Status(http.StatusOK)\n}\n\n// Logout handles logout\nfunc (h *AuthHandler) Logout(c *gin.Context) {\n\tif err := h.sessionService.DestroySession(c.Writer, c.Request); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"Failed to logout\"})\n\t\treturn\n\t}\n\n\tc.Redirect(http.StatusFound, \"/login\")\n}\n\n// ShowForgotPasswordPage displays the forgot password page\nfunc (h *AuthHandler) ShowForgotPasswordPage(c *gin.Context) {\n\tc.HTML(http.StatusOK, \"forgot-password.html\", gin.H{})\n}\n\n// ForgotPassword handles forgot password request\nfunc (h *AuthHandler) ForgotPassword(c *gin.Context) {\n\t// Generate reset token\n\ttoken, err := h.settingsService.GenerateResetToken()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"forgot-password-error.html\", gin.H{\n\t\t\t\"Error\": \"Failed to generate reset token\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Check if SMTP is configured\n\t_, err = h.settingsService.GetSMTPConfig()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"forgot-password-error.html\", gin.H{\n\t\t\t\"Error\": \"Email is not configured. Please contact administrator.\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Build reset URL\n\tresetURL := buildBaseURL(c, h.settingsService.GetBaseURL()) + \"/reset-password?token=\" + url.QueryEscape(token)\n\n\t// Send reset email\n\tsubject := \"SubTrackr Password Reset\"\n\tbody := fmt.Sprintf(`\n\t\t<h2>Password Reset Request</h2>\n\t\t<p>You have requested to reset your SubTrackr password.</p>\n\t\t<p>Click the link below to reset your password:</p>\n\t\t<p><a href=\"%s\">Reset Password</a></p>\n\t\t<p>This link will expire in 1 hour.</p>\n\t\t<p>If you did not request this reset, please ignore this email.</p>\n\t`, resetURL)\n\n\terr = h.emailService.SendEmail(subject, body)\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"forgot-password-error.html\", gin.H{\n\t\t\t\"Error\": \"Failed to send reset email: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"forgot-password-success.html\", gin.H{\n\t\t\"Message\": \"Password reset link has been sent to your email\",\n\t})\n}\n\n// ShowResetPasswordPage displays the reset password page\nfunc (h *AuthHandler) ShowResetPasswordPage(c *gin.Context) {\n\ttoken := c.Query(\"token\")\n\tif token == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"reset-password.html\", gin.H{\n\t\t\t\"Error\": \"Invalid reset token\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Validate token\n\tif err := h.settingsService.ValidateResetToken(token); err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"reset-password.html\", gin.H{\n\t\t\t\"Error\": \"Invalid or expired reset token\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"reset-password.html\", gin.H{\n\t\t\"Token\": token,\n\t})\n}\n\n// ResetPassword handles password reset\nfunc (h *AuthHandler) ResetPassword(c *gin.Context) {\n\ttoken := c.PostForm(\"token\")\n\tnewPassword := c.PostForm(\"new_password\")\n\tconfirmPassword := c.PostForm(\"confirm_password\")\n\n\t// Validate password length FIRST (before checking if they match)\n\tif len(newPassword) < 8 {\n\t\tc.HTML(http.StatusBadRequest, \"reset-password-error.html\", gin.H{\n\t\t\t\"Error\": \"Password must be at least 8 characters long\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Then validate passwords match\n\tif newPassword != confirmPassword {\n\t\tc.HTML(http.StatusBadRequest, \"reset-password-error.html\", gin.H{\n\t\t\t\"Error\": \"Passwords do not match\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Validate token\n\tif err := h.settingsService.ValidateResetToken(token); err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"reset-password-error.html\", gin.H{\n\t\t\t\"Error\": \"Invalid or expired reset token\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Update password\n\tif err := h.settingsService.SetAuthPassword(newPassword); err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"reset-password-error.html\", gin.H{\n\t\t\t\"Error\": \"Failed to update password\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Clear reset token\n\th.settingsService.ClearResetToken()\n\n\tc.HTML(http.StatusOK, \"reset-password-success.html\", gin.H{\n\t\t\"Message\": \"Password reset successfully. You can now login with your new password.\",\n\t})\n}\n"
  },
  {
    "path": "internal/handlers/category.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/service\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype CategoryHandler struct {\n\tservice *service.CategoryService\n}\n\nfunc NewCategoryHandler(service *service.CategoryService) *CategoryHandler {\n\treturn &CategoryHandler{service: service}\n}\n\n// List all categories\nfunc (h *CategoryHandler) ListCategories(c *gin.Context) {\n\tcategories, err := h.service.GetAll()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, categories)\n}\n\n// Create a new category\nfunc (h *CategoryHandler) CreateCategory(c *gin.Context) {\n\tvar category models.Category\n\tif err := c.ShouldBindJSON(&category); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tcreated, err := h.service.Create(&category)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusCreated, created)\n}\n\n// Update a category\nfunc (h *CategoryHandler) UpdateCategory(c *gin.Context) {\n\tid, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid ID\"})\n\t\treturn\n\t}\n\tvar category models.Category\n\tif err := c.ShouldBindJSON(&category); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tupdated, err := h.service.Update(uint(id), &category)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, updated)\n}\n\n// Delete a category\nfunc (h *CategoryHandler) DeleteCategory(c *gin.Context) {\n\tid, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid ID\"})\n\t\treturn\n\t}\n\tif err := h.service.Delete(uint(id)); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "internal/handlers/settings.go",
    "content": "package handlers\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/tls\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/smtp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/service\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc splitLines(s string) []string { return strings.Split(s, \"\\n\") }\nfunc trimSpace(s string) string    { return strings.TrimSpace(s) }\nfunc splitN(s, sep string, n int) []string { return strings.SplitN(s, sep, n) }\n\ntype SettingsHandler struct {\n\tservice *service.SettingsService\n}\n\nfunc NewSettingsHandler(service *service.SettingsService) *SettingsHandler {\n\treturn &SettingsHandler{service: service}\n}\n\n// SaveSMTPSettings saves SMTP configuration\nfunc (h *SettingsHandler) SaveSMTPSettings(c *gin.Context) {\n\tvar config models.SMTPConfig\n\n\t// Parse form data\n\tconfig.Host = c.PostForm(\"smtp_host\")\n\tconfig.Username = c.PostForm(\"smtp_username\")\n\tconfig.Password = c.PostForm(\"smtp_password\")\n\tconfig.From = c.PostForm(\"smtp_from\")\n\tconfig.FromName = c.PostForm(\"smtp_from_name\")\n\tconfig.To = c.PostForm(\"smtp_to\")\n\n\t// Parse port\n\tif portStr := c.PostForm(\"smtp_port\"); portStr != \"\" {\n\t\tif port, err := strconv.Atoi(portStr); err == nil {\n\t\t\tconfig.Port = port\n\t\t}\n\t}\n\n\t// Validate required fields\n\tif config.Host == \"\" || config.Port == 0 || config.Username == \"\" || config.Password == \"\" || config.From == \"\" || config.To == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": \"Required SMTP fields: Host, Port, Username, Password, From email, To email\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Save configuration\n\terr := h.service.SaveSMTPConfig(&config)\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"smtp-message.html\", gin.H{\n\t\t\"Message\": \"SMTP settings saved successfully\",\n\t\t\"Type\":    \"success\",\n\t})\n}\n\n// TestSMTPConnection tests SMTP configuration with TLS/SSL support\nfunc (h *SettingsHandler) TestSMTPConnection(c *gin.Context) {\n\tvar config models.SMTPConfig\n\n\t// Parse form data\n\tconfig.Host = c.PostForm(\"smtp_host\")\n\tconfig.Username = c.PostForm(\"smtp_username\")\n\tconfig.Password = c.PostForm(\"smtp_password\")\n\tconfig.From = c.PostForm(\"smtp_from\")\n\tconfig.FromName = c.PostForm(\"smtp_from_name\")\n\tconfig.To = c.PostForm(\"smtp_to\")\n\n\t// Parse port\n\tif portStr := c.PostForm(\"smtp_port\"); portStr != \"\" {\n\t\tif port, err := strconv.Atoi(portStr); err == nil {\n\t\t\tconfig.Port = port\n\t\t}\n\t}\n\n\t// Validate required fields for testing (connection test doesn't need From/To, but we validate for consistency)\n\tif config.Host == \"\" || config.Port == 0 || config.Username == \"\" || config.Password == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": \"Host, Port, Username, and Password are required for testing\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Test connection with TLS/SSL support\n\taddr := fmt.Sprintf(\"%s:%d\", config.Host, config.Port)\n\tauth := smtp.PlainAuth(\"\", config.Username, config.Password, config.Host)\n\n\t// Determine if this is an implicit TLS port (SMTPS)\n\tisSSLPort := config.Port == 465 || config.Port == 8465 || config.Port == 443\n\n\tvar client *smtp.Client\n\tvar err error\n\n\tif isSSLPort {\n\t\t// Use implicit TLS (direct SSL connection)\n\t\ttlsConfig := &tls.Config{\n\t\t\tServerName: config.Host,\n\t\t}\n\n\t\tconn, err := tls.Dial(\"tcp\", addr, tlsConfig)\n\t\tif err != nil {\n\t\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\t\"Error\": fmt.Sprintf(\"Failed to connect via SSL: %v\", err),\n\t\t\t\t\"Type\":  \"error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tclient, err = smtp.NewClient(conn, config.Host)\n\t\tif err != nil {\n\t\t\tconn.Close()\n\t\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\t\"Error\": fmt.Sprintf(\"Failed to create SMTP client: %v\", err),\n\t\t\t\t\"Type\":  \"error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t} else {\n\t\t// Use STARTTLS (opportunistic TLS)\n\t\tclient, err = smtp.Dial(addr)\n\t\tif err != nil {\n\t\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\t\"Error\": fmt.Sprintf(\"Failed to connect: %v\", err),\n\t\t\t\t\"Type\":  \"error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// Upgrade to TLS\n\t\ttlsConfig := &tls.Config{\n\t\t\tServerName: config.Host,\n\t\t}\n\n\t\tif err = client.StartTLS(tlsConfig); err != nil {\n\t\t\tclient.Close()\n\t\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\t\"Error\": fmt.Sprintf(\"Failed to start TLS: %v\", err),\n\t\t\t\t\"Type\":  \"error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tdefer client.Close()\n\n\t// Try to authenticate\n\tif err = client.Auth(auth); err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": fmt.Sprintf(\"Authentication failed: %v\", err),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"smtp-message.html\", gin.H{\n\t\t\"Message\": \"SMTP connection test successful!\",\n\t\t\"Type\":    \"success\",\n\t})\n}\n\n// UpdateNotificationSetting updates a notification preference\nfunc (h *SettingsHandler) UpdateNotificationSetting(c *gin.Context) {\n\tsetting := c.Param(\"setting\")\n\n\tswitch setting {\n\tcase \"renewal\":\n\t\tcurrent, _ := h.service.GetBoolSetting(\"renewal_reminders\", false)\n\t\terr := h.service.SetBoolSetting(\"renewal_reminders\", !current)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, gin.H{\"enabled\": !current})\n\n\tcase \"highcost\":\n\t\tcurrent, _ := h.service.GetBoolSetting(\"high_cost_alerts\", true)\n\t\terr := h.service.SetBoolSetting(\"high_cost_alerts\", !current)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, gin.H{\"enabled\": !current})\n\n\tcase \"days\":\n\t\tdaysStr := c.PostForm(\"reminder_days\")\n\t\tif days, err := strconv.Atoi(daysStr); err == nil && days > 0 && days <= 30 {\n\t\t\terr := h.service.SetIntSetting(\"reminder_days\", days)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tc.JSON(http.StatusOK, gin.H{\"days\": days})\n\t\t} else {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid days value\"})\n\t\t}\n\n\tcase \"threshold\":\n\t\tthresholdStr := c.PostForm(\"high_cost_threshold\")\n\t\tif threshold, err := strconv.ParseFloat(thresholdStr, 64); err == nil && threshold >= 0 && threshold <= 10000 {\n\t\t\terr := h.service.SetFloatSetting(\"high_cost_threshold\", threshold)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tc.JSON(http.StatusOK, gin.H{\"threshold\": threshold})\n\t\t} else {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid threshold value (must be between 0 and 10000)\"})\n\t\t}\n\n\tcase \"cancellation\":\n\t\tcurrent, _ := h.service.GetBoolSetting(\"cancellation_reminders\", false)\n\t\terr := h.service.SetBoolSetting(\"cancellation_reminders\", !current)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, gin.H{\"enabled\": !current})\n\n\tcase \"cancellation_days\":\n\t\tdaysStr := c.PostForm(\"cancellation_reminder_days\")\n\t\tif days, err := strconv.Atoi(daysStr); err == nil && days > 0 && days <= 30 {\n\t\t\terr := h.service.SetIntSetting(\"cancellation_reminder_days\", days)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tc.JSON(http.StatusOK, gin.H{\"days\": days})\n\t\t} else {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid days value\"})\n\t\t}\n\n\tdefault:\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Unknown setting\"})\n\t}\n}\n\n// GetNotificationSettings returns current notification settings\nfunc (h *SettingsHandler) GetNotificationSettings(c *gin.Context) {\n\tsettings := models.NotificationSettings{\n\t\tRenewalReminders:         h.service.GetBoolSettingWithDefault(\"renewal_reminders\", false),\n\t\tHighCostAlerts:           h.service.GetBoolSettingWithDefault(\"high_cost_alerts\", true),\n\t\tHighCostThreshold:        h.service.GetFloatSettingWithDefault(\"high_cost_threshold\", 50.0),\n\t\tReminderDays:             h.service.GetIntSettingWithDefault(\"reminder_days\", 7),\n\t\tCancellationReminders:    h.service.GetBoolSettingWithDefault(\"cancellation_reminders\", false),\n\t\tCancellationReminderDays: h.service.GetIntSettingWithDefault(\"cancellation_reminder_days\", 7),\n\t}\n\n\tc.JSON(http.StatusOK, settings)\n}\n\n// GetSMTPConfig returns current SMTP configuration (without password)\nfunc (h *SettingsHandler) GetSMTPConfig(c *gin.Context) {\n\tconfig, err := h.service.GetSMTPConfig()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\"configured\": false})\n\t\treturn\n\t}\n\n\t// Don't send the password\n\tconfig.Password = \"\"\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"configured\": true,\n\t\t\"config\":     config,\n\t})\n}\n\n// ListAPIKeys returns all API keys\nfunc (h *SettingsHandler) ListAPIKeys(c *gin.Context) {\n\tkeys, err := h.service.GetAllAPIKeys()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"api-keys-list.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Don't send the actual key values for existing keys\n\tfor i := range keys {\n\t\tif !keys[i].IsNew {\n\t\t\tkeys[i].Key = \"\"\n\t\t}\n\t}\n\n\tc.HTML(http.StatusOK, \"api-keys-list.html\", gin.H{\n\t\t\"Keys\":         keys,\n\t\t\"GoDateFormat\": h.service.GetGoDateFormat(),\n\t})\n}\n\n// CreateAPIKey generates a new API key\nfunc (h *SettingsHandler) CreateAPIKey(c *gin.Context) {\n\tname := c.PostForm(\"name\")\n\tif name == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"api-keys-list.html\", gin.H{\n\t\t\t\"Error\": \"API key name is required\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Generate a secure random API key\n\tkeyBytes := make([]byte, 32)\n\tif _, err := rand.Read(keyBytes); err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"api-keys-list.html\", gin.H{\n\t\t\t\"Error\": \"Failed to generate API key\",\n\t\t})\n\t\treturn\n\t}\n\n\tapiKey := \"sk_\" + hex.EncodeToString(keyBytes)\n\n\t// Save the API key\n\tnewKey, err := h.service.CreateAPIKey(name, apiKey)\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"api-keys-list.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Get all keys including the new one\n\tkeys, err := h.service.GetAllAPIKeys()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"api-keys-list.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Mark the new key and include its value\n\tfor i := range keys {\n\t\tif keys[i].ID == newKey.ID {\n\t\t\tkeys[i].IsNew = true\n\t\t\tkeys[i].Key = apiKey\n\t\t} else {\n\t\t\tkeys[i].Key = \"\"\n\t\t}\n\t}\n\n\tc.HTML(http.StatusOK, \"api-keys-list.html\", gin.H{\n\t\t\"Keys\":         keys,\n\t\t\"GoDateFormat\": h.service.GetGoDateFormat(),\n\t})\n}\n\n// DeleteAPIKey removes an API key\nfunc (h *SettingsHandler) DeleteAPIKey(c *gin.Context) {\n\tidStr := c.Param(\"id\")\n\tid, err := strconv.ParseUint(idStr, 10, 32)\n\tif err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"api-keys-list.html\", gin.H{\n\t\t\t\"Error\": \"Invalid API key ID\",\n\t\t})\n\t\treturn\n\t}\n\n\terr = h.service.DeleteAPIKey(uint(id))\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"api-keys-list.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Return updated list\n\th.ListAPIKeys(c)\n}\n\n// UpdateCurrency updates the currency preference\nfunc (h *SettingsHandler) UpdateCurrency(c *gin.Context) {\n\tcurrency := c.PostForm(\"currency\")\n\n\terr := h.service.SetCurrency(currency)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"currency\": currency,\n\t\t\"symbol\":   h.service.GetCurrencySymbol(),\n\t})\n}\n\n// UpdateDateFormat updates the date format preference\nfunc (h *SettingsHandler) UpdateDateFormat(c *gin.Context) {\n\tformat := c.PostForm(\"date_format\")\n\n\terr := h.service.SetDateFormat(format)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\"date_format\": format})\n}\n\n// ToggleDarkMode toggles dark mode preference\nfunc (h *SettingsHandler) ToggleDarkMode(c *gin.Context) {\n\tenabled := c.PostForm(\"enabled\") == \"true\"\n\n\terr := h.service.SetDarkMode(enabled)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"dark_mode\": enabled,\n\t})\n}\n\n// SetupAuth enables authentication with username and password\nfunc (h *SettingsHandler) SetupAuth(c *gin.Context) {\n\tusername := c.PostForm(\"username\")\n\tpassword := c.PostForm(\"password\")\n\tconfirmPassword := c.PostForm(\"confirm_password\")\n\n\t// Validate inputs\n\tif username == \"\" || password == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"auth-message.html\", gin.H{\n\t\t\t\"Error\": \"Username and password are required\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tif password != confirmPassword {\n\t\tc.HTML(http.StatusBadRequest, \"auth-message.html\", gin.H{\n\t\t\t\"Error\": \"Passwords do not match\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tif len(password) < 8 {\n\t\tc.HTML(http.StatusBadRequest, \"auth-message.html\", gin.H{\n\t\t\t\"Error\": \"Password must be at least 8 characters long\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Check if SMTP is configured (required for password reset)\n\t_, err := h.service.GetSMTPConfig()\n\tif err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"auth-message.html\", gin.H{\n\t\t\t\"Error\": \"Please configure email settings first (required for password recovery)\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Setup authentication\n\terr = h.service.SetupAuth(username, password)\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"auth-message.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"auth-message.html\", gin.H{\n\t\t\"Message\": \"Authentication enabled successfully. You will need to login on next page load.\",\n\t\t\"Type\":    \"success\",\n\t})\n}\n\n// DisableAuth disables authentication\nfunc (h *SettingsHandler) DisableAuth(c *gin.Context) {\n\terr := h.service.DisableAuth()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"auth-message.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"auth-message.html\", gin.H{\n\t\t\"Message\": \"Authentication disabled successfully\",\n\t\t\"Type\":    \"success\",\n\t})\n}\n\n// GetAuthStatus returns the current authentication status\nfunc (h *SettingsHandler) GetAuthStatus(c *gin.Context) {\n\tisEnabled := h.service.IsAuthEnabled()\n\tusername, _ := h.service.GetAuthUsername()\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"enabled\":  isEnabled,\n\t\t\"username\": username,\n\t})\n}\n\n// GetTheme returns the current theme setting\nfunc (h *SettingsHandler) GetTheme(c *gin.Context) {\n\ttheme, err := h.service.GetTheme()\n\tif err != nil {\n\t\t// Default to 'default' theme if not set\n\t\ttheme = \"default\"\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"theme\": theme,\n\t})\n}\n\n// SavePushoverSettings saves Pushover configuration\nfunc (h *SettingsHandler) SavePushoverSettings(c *gin.Context) {\n\tvar config models.PushoverConfig\n\n\t// Parse form data\n\tconfig.UserKey = c.PostForm(\"pushover_user_key\")\n\tconfig.AppToken = c.PostForm(\"pushover_app_token\")\n\n\t// Validate required fields\n\tif config.UserKey == \"\" || config.AppToken == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": \"User Key and App Token are required\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Save configuration\n\terr := h.service.SavePushoverConfig(&config)\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"smtp-message.html\", gin.H{\n\t\t\"Message\": \"Pushover settings saved successfully\",\n\t\t\"Type\":    \"success\",\n\t})\n}\n\n// TestPushoverConnection tests Pushover configuration\nfunc (h *SettingsHandler) TestPushoverConnection(c *gin.Context) {\n\tvar config models.PushoverConfig\n\n\t// Parse form data\n\tconfig.UserKey = c.PostForm(\"pushover_user_key\")\n\tconfig.AppToken = c.PostForm(\"pushover_app_token\")\n\n\t// Validate required fields\n\tif config.UserKey == \"\" || config.AppToken == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": \"User Key and App Token are required for testing\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Create a temporary PushoverService to test\n\tpushoverService := service.NewPushoverService(h.service)\n\n\t// Temporarily save config for testing\n\toriginalConfig, _ := h.service.GetPushoverConfig()\n\tdefer func() {\n\t\tvar restoreErr error\n\t\tif originalConfig != nil {\n\t\t\trestoreErr = h.service.SavePushoverConfig(originalConfig)\n\t\t} else {\n\t\t\t// No original config existed, so delete the test config by saving empty values\n\t\t\trestoreErr = h.service.SavePushoverConfig(&models.PushoverConfig{\n\t\t\t\tUserKey:  \"\",\n\t\t\t\tAppToken: \"\",\n\t\t\t})\n\t\t}\n\t\tif restoreErr != nil {\n\t\t\tlog.Printf(\"Warning: failed to restore Pushover config after test: %v\", restoreErr)\n\t\t}\n\t}()\n\n\t// Save test config\n\tif err := h.service.SavePushoverConfig(&config); err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": fmt.Sprintf(\"Failed to save test config: %v\", err),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Send test notification\n\terr := pushoverService.SendNotification(\"SubTrackr Test\", \"This is a test notification from SubTrackr. If you received this, your Pushover configuration is working correctly!\", 0)\n\tif err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": fmt.Sprintf(\"Failed to send test notification: %v\", err),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"smtp-message.html\", gin.H{\n\t\t\"Message\": \"Pushover connection test successful! Check your device for the test notification.\",\n\t\t\"Type\":    \"success\",\n\t})\n}\n\n// SaveWebhookSettings saves Webhook configuration\nfunc (h *SettingsHandler) SaveWebhookSettings(c *gin.Context) {\n\tvar config models.WebhookConfig\n\tconfig.URL = c.PostForm(\"webhook_url\")\n\n\tif config.URL == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": \"Webhook URL is required\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Validate URL scheme to prevent SSRF\n\tif !strings.HasPrefix(config.URL, \"http://\") && !strings.HasPrefix(config.URL, \"https://\") {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": \"Webhook URL must use http:// or https:// scheme\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Parse headers from textarea (Key: Value format, one per line)\n\theadersRaw := c.PostForm(\"webhook_headers\")\n\theaders := make(map[string]string)\n\tfor _, line := range splitLines(headersRaw) {\n\t\tline = trimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparts := splitN(line, \":\", 2)\n\t\tif len(parts) == 2 {\n\t\t\theaders[trimSpace(parts[0])] = trimSpace(parts[1])\n\t\t}\n\t}\n\tconfig.Headers = headers\n\n\terr := h.service.SaveWebhookConfig(&config)\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"smtp-message.html\", gin.H{\n\t\t\"Message\": \"Webhook settings saved successfully\",\n\t\t\"Type\":    \"success\",\n\t})\n}\n\n// TestWebhookConnection tests Webhook configuration\nfunc (h *SettingsHandler) TestWebhookConnection(c *gin.Context) {\n\twebhookURL := c.PostForm(\"webhook_url\")\n\tif webhookURL == \"\" {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": \"Webhook URL is required for testing\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Validate URL scheme to prevent SSRF\n\tif !strings.HasPrefix(webhookURL, \"http://\") && !strings.HasPrefix(webhookURL, \"https://\") {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": \"Webhook URL must use http:// or https:// scheme\",\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Parse headers\n\theadersRaw := c.PostForm(\"webhook_headers\")\n\theaders := make(map[string]string)\n\tfor _, line := range splitLines(headersRaw) {\n\t\tline = trimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparts := splitN(line, \":\", 2)\n\t\tif len(parts) == 2 {\n\t\t\theaders[trimSpace(parts[0])] = trimSpace(parts[1])\n\t\t}\n\t}\n\n\ttestConfig := &models.WebhookConfig{URL: webhookURL, Headers: headers}\n\n\t// Temporarily save config for testing\n\toriginalConfig, _ := h.service.GetWebhookConfig()\n\tdefer func() {\n\t\tvar restoreErr error\n\t\tif originalConfig != nil {\n\t\t\trestoreErr = h.service.SaveWebhookConfig(originalConfig)\n\t\t} else {\n\t\t\trestoreErr = h.service.SaveWebhookConfig(&models.WebhookConfig{})\n\t\t}\n\t\tif restoreErr != nil {\n\t\t\tlog.Printf(\"Warning: failed to restore webhook config after test: %v\", restoreErr)\n\t\t}\n\t}()\n\n\tif err := h.service.SaveWebhookConfig(testConfig); err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": fmt.Sprintf(\"Failed to save test config: %v\", err),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\twebhookService := service.NewWebhookService(h.service)\n\tpayload := &service.WebhookPayload{\n\t\tEvent:     \"test\",\n\t\tTitle:     \"SubTrackr Test\",\n\t\tMessage:   \"This is a test notification from SubTrackr. If you received this, your webhook configuration is working correctly!\",\n\t\tTimestamp: time.Now().UTC().Format(time.RFC3339),\n\t}\n\n\terr := webhookService.SendWebhook(payload)\n\tif err != nil {\n\t\tc.HTML(http.StatusBadRequest, \"smtp-message.html\", gin.H{\n\t\t\t\"Error\": fmt.Sprintf(\"Webhook test failed: %v\", err),\n\t\t\t\"Type\":  \"error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"smtp-message.html\", gin.H{\n\t\t\"Message\": \"Webhook test successful! Check your endpoint for the test payload.\",\n\t\t\"Type\":    \"success\",\n\t})\n}\n\n// GetPushoverConfig returns current Pushover configuration (without sensitive data)\nfunc (h *SettingsHandler) GetPushoverConfig(c *gin.Context) {\n\tconfig, err := h.service.GetPushoverConfig()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\"configured\": false})\n\t\treturn\n\t}\n\n\t// Don't send the full token, just indicate if configured\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"configured\":    true,\n\t\t\"has_user_key\":  config.UserKey != \"\",\n\t\t\"has_app_token\": config.AppToken != \"\",\n\t})\n}\n\n// ToggleICalSubscription toggles iCal subscription on/off\nfunc (h *SettingsHandler) ToggleICalSubscription(c *gin.Context) {\n\tcurrent := h.service.IsICalSubscriptionEnabled()\n\tnewState := !current\n\n\tif err := h.service.SetICalSubscriptionEnabled(newState); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tvar url string\n\tif newState {\n\t\ttoken, err := h.service.GetOrGenerateICalToken()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t\turl = buildBaseURL(c, h.service.GetBaseURL()) + \"/ical/\" + token\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"enabled\": newState,\n\t\t\"url\":     url,\n\t})\n}\n\n// GetICalSubscriptionURL returns the current iCal subscription status and URL\nfunc (h *SettingsHandler) GetICalSubscriptionURL(c *gin.Context) {\n\tenabled := h.service.IsICalSubscriptionEnabled()\n\tvar url string\n\tif enabled {\n\t\ttoken, err := h.service.GetOrGenerateICalToken()\n\t\tif err == nil {\n\t\t\turl = buildBaseURL(c, h.service.GetBaseURL()) + \"/ical/\" + token\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"enabled\": enabled,\n\t\t\"url\":     url,\n\t})\n}\n\n// RegenerateICalToken generates a new iCal subscription token\nfunc (h *SettingsHandler) RegenerateICalToken(c *gin.Context) {\n\ttoken, err := h.service.RegenerateICalToken()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\turl := buildBaseURL(c, h.service.GetBaseURL()) + \"/ical/\" + token\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"url\": url,\n\t})\n}\n\n// UpdateBaseURL saves the base URL setting\nfunc (h *SettingsHandler) UpdateBaseURL(c *gin.Context) {\n\tbaseURL := c.PostForm(\"base_url\")\n\n\tif err := h.service.SetBaseURL(baseURL); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"base_url\": baseURL,\n\t})\n}\n\n// SetTheme saves the theme preference\nfunc (h *SettingsHandler) SetTheme(c *gin.Context) {\n\tvar req struct {\n\t\tTheme string `json:\"theme\" binding:\"required\"`\n\t}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\n\t\t\t\"error\": \"Invalid request\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Validate theme name\n\tvalidThemes := map[string]bool{\n\t\t\"default\":   true,\n\t\t\"dark\":      true,\n\t\t\"christmas\": true,\n\t\t\"midnight\":  true,\n\t\t\"ocean\":     true,\n\t}\n\n\tif !validThemes[req.Theme] {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\n\t\t\t\"error\": \"Invalid theme name\",\n\t\t})\n\t\treturn\n\t}\n\n\tif err := h.service.SetTheme(req.Theme); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\n\t\t\t\"error\": \"Failed to save theme\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"theme\":   req.Theme,\n\t})\n}\n"
  },
  {
    "path": "internal/handlers/subscription.go",
    "content": "package handlers\n\nimport (\n\t\"encoding/csv\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"log\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/service\"\n\t\"subtrackr/internal/version\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// SubscriptionWithConversion represents a subscription with currency conversion info\ntype SubscriptionWithConversion struct {\n\t*models.Subscription\n\tConvertedCost         float64 `json:\"converted_cost\"`\n\tConvertedAnnualCost   float64 `json:\"converted_annual_cost\"`\n\tConvertedMonthlyCost  float64 `json:\"converted_monthly_cost\"`\n\tDisplayCurrency       string  `json:\"display_currency\"`\n\tDisplayCurrencySymbol string  `json:\"display_currency_symbol\"`\n\tShowConversion        bool    `json:\"show_conversion\"`\n}\n\ntype SubscriptionHandler struct {\n\tservice         *service.SubscriptionService\n\tsettingsService *service.SettingsService\n\tcurrencyService *service.CurrencyService\n\temailService    *service.EmailService\n\tpushoverService *service.PushoverService\n\twebhookService  *service.WebhookService\n\tlogoService     *service.LogoService\n\tcategoryService *service.CategoryService\n}\n\nfunc NewSubscriptionHandler(service *service.SubscriptionService, settingsService *service.SettingsService, currencyService *service.CurrencyService, emailService *service.EmailService, pushoverService *service.PushoverService, webhookService *service.WebhookService, logoService *service.LogoService, categoryService *service.CategoryService) *SubscriptionHandler {\n\treturn &SubscriptionHandler{\n\t\tservice:         service,\n\t\tsettingsService: settingsService,\n\t\tcurrencyService: currencyService,\n\t\temailService:    emailService,\n\t\tpushoverService: pushoverService,\n\t\twebhookService:  webhookService,\n\t\tlogoService:     logoService,\n\t\tcategoryService: categoryService,\n\t}\n}\n\n// enrichWithCurrencyConversion adds currency conversion info to subscriptions\nfunc (h *SubscriptionHandler) enrichWithCurrencyConversion(subscriptions []models.Subscription) []SubscriptionWithConversion {\n\tdisplayCurrency := h.settingsService.GetCurrency()\n\tdisplaySymbol := h.settingsService.GetCurrencySymbol()\n\n\tresult := make([]SubscriptionWithConversion, len(subscriptions))\n\n\tfor i := range subscriptions {\n\t\t// Create a copy of the subscription for modification; this pattern is correct for Go 1.22+\n\t\tsub := subscriptions[i]\n\t\tenriched := SubscriptionWithConversion{\n\t\t\tSubscription:          &sub,\n\t\t\tDisplayCurrency:       displayCurrency,\n\t\t\tDisplayCurrencySymbol: displaySymbol,\n\t\t\tShowConversion:        false,\n\t\t}\n\n\t\tif h.currencyService.IsEnabled() && sub.OriginalCurrency != \"\" && sub.OriginalCurrency != displayCurrency {\n\t\t\tif convertedCost, err := h.currencyService.ConvertAmount(sub.Cost, sub.OriginalCurrency, displayCurrency); err == nil {\n\t\t\t\tenriched.ConvertedCost = convertedCost\n\t\t\t\tratio := convertedCost / sub.Cost\n\t\t\t\tenriched.ConvertedAnnualCost = sub.AnnualCost() * ratio\n\t\t\t\tenriched.ConvertedMonthlyCost = sub.MonthlyCost() * ratio\n\t\t\t\tenriched.ShowConversion = true\n\t\t\t}\n\t\t} else if sub.OriginalCurrency != \"\" && sub.OriginalCurrency != displayCurrency {\n\t\t\t// Different currency but conversion not available - show original currency\n\t\t\tenriched.ConvertedCost = sub.Cost\n\t\t\tenriched.ConvertedAnnualCost = sub.AnnualCost()\n\t\t\tenriched.ConvertedMonthlyCost = sub.MonthlyCost()\n\t\t\tenriched.DisplayCurrency = sub.OriginalCurrency\n\t\t\tenriched.DisplayCurrencySymbol = service.CurrencySymbolForCode(sub.OriginalCurrency)\n\t\t} else {\n\t\t\t// Same currency or no conversion needed\n\t\t\tenriched.ConvertedCost = sub.Cost\n\t\t\tenriched.ConvertedAnnualCost = sub.AnnualCost()\n\t\t\tenriched.ConvertedMonthlyCost = sub.MonthlyCost()\n\t\t}\n\n\t\tresult[i] = enriched\n\t}\n\n\treturn result\n}\n\n// isHighCostWithCurrency checks if a subscription is high-cost, respecting currency conversion\n// The threshold is in the user's display currency, so we convert the subscription's monthly cost\n// to the display currency before comparing\nfunc (h *SubscriptionHandler) isHighCostWithCurrency(subscription *models.Subscription) bool {\n\tthreshold := h.settingsService.GetFloatSettingWithDefault(\"high_cost_threshold\", 50.0)\n\tdisplayCurrency := h.settingsService.GetCurrency()\n\n\t// Get monthly cost in subscription's original currency\n\tmonthlyCost := subscription.MonthlyCost()\n\n\t// If currencies match or conversion is disabled, compare directly\n\tif subscription.OriginalCurrency == displayCurrency || !h.currencyService.IsEnabled() {\n\t\treturn monthlyCost > threshold\n\t}\n\n\t// Convert monthly cost to display currency\n\tconvertedMonthlyCost, err := h.currencyService.ConvertAmount(monthlyCost, subscription.OriginalCurrency, displayCurrency)\n\tif err != nil {\n\t\t// If conversion fails, fall back to direct comparison\n\t\t// Note: This may not be accurate if currencies differ, but prevents silent failures\n\t\t// The warning log helps identify when this fallback is used\n\t\tlog.Printf(\"Warning: Failed to convert currency for high-cost check (%s to %s): %v. Using direct comparison.\", subscription.OriginalCurrency, displayCurrency, err)\n\t\treturn monthlyCost > threshold\n\t}\n\n\t// Compare converted monthly cost against threshold\n\treturn convertedMonthlyCost > threshold\n}\n\n// fetchAndSetLogo fetches a logo for a subscription if URL is provided and icon_url is empty\n// This is a helper method to avoid code duplication between create and update handlers\nfunc (h *SubscriptionHandler) fetchAndSetLogo(subscription *models.Subscription) {\n\tif subscription.URL == \"\" || subscription.IconURL != \"\" {\n\t\treturn\n\t}\n\n\ticonURL, err := h.logoService.FetchLogoFromURL(subscription.URL)\n\tif err == nil && iconURL != \"\" {\n\t\tsubscription.IconURL = iconURL\n\t\tlog.Printf(\"Fetched logo: %s -> %s\", subscription.URL, iconURL)\n\t} else if err != nil {\n\t\tlog.Printf(\"Failed to fetch logo for URL %s: %v\", subscription.URL, err)\n\t}\n}\n\nfunc parseScheduleInterval(s string) int {\n\tif s == \"\" {\n\t\treturn 1\n\t}\n\tv, err := strconv.Atoi(s)\n\tif err != nil || v < 1 {\n\t\treturn 1\n\t}\n\treturn v\n}\n\n// parseDatePtr parses a date string in \"2006-01-02\" format and returns a pointer to time.Time.\n// Returns nil if the string is empty or if parsing fails.\n// Logs parsing errors for debugging purposes.\nfunc parseDatePtr(dateStr string) *time.Time {\n\tif dateStr == \"\" {\n\t\treturn nil\n\t}\n\tif date, err := time.Parse(\"2006-01-02\", dateStr); err == nil {\n\t\treturn &date\n\t}\n\t// Log parsing errors for debugging (invalid date format from form)\n\tlog.Printf(\"Failed to parse date string '%s': expected format YYYY-MM-DD\", dateStr)\n\treturn nil\n}\n\n// Dashboard renders the main dashboard page\nfunc (h *SubscriptionHandler) Dashboard(c *gin.Context) {\n\tstats, err := h.service.GetStats()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"error.html\", gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tsubscriptions, err := h.service.GetAll()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"error.html\", gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// Enrich with currency conversion\n\tenrichedSubs := h.enrichWithCurrencyConversion(subscriptions)\n\n\tc.HTML(http.StatusOK, \"dashboard.html\", gin.H{\n\t\t\"Title\":          \"Dashboard\",\n\t\t\"CurrentPage\":    \"dashboard\",\n\t\t\"Stats\":          stats,\n\t\t\"Subscriptions\":  enrichedSubs,\n\t\t\"CurrencySymbol\": h.settingsService.GetCurrencySymbol(),\n\t\t\"DarkMode\":       h.settingsService.IsDarkModeEnabled(),\n\t})\n}\n\n// SubscriptionsList renders the subscriptions list page\nfunc (h *SubscriptionHandler) SubscriptionsList(c *gin.Context) {\n\t// Get sort parameters from query string\n\tsortBy := c.DefaultQuery(\"sort\", \"created_at\")\n\torder := c.DefaultQuery(\"order\", \"desc\")\n\n\t// Get sorted subscriptions\n\tsubscriptions, err := h.service.GetAllSorted(sortBy, order)\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"error.html\", gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// Enrich with currency conversion\n\tenrichedSubs := h.enrichWithCurrencyConversion(subscriptions)\n\n\tc.HTML(http.StatusOK, \"subscriptions.html\", gin.H{\n\t\t\"Title\":          \"Subscriptions\",\n\t\t\"CurrentPage\":    \"subscriptions\",\n\t\t\"Subscriptions\":  enrichedSubs,\n\t\t\"CurrencySymbol\": h.settingsService.GetCurrencySymbol(),\n\t\t\"DarkMode\":       h.settingsService.IsDarkModeEnabled(),\n\t\t\"SortBy\":         sortBy,\n\t\t\"Order\":          order,\n\t\t\"GoDateFormat\":   h.settingsService.GetGoDateFormat(),\n\t})\n}\n\n// Analytics renders the analytics page\nfunc (h *SubscriptionHandler) Analytics(c *gin.Context) {\n\tstats, err := h.service.GetStats()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"error.html\", gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.HTML(http.StatusOK, \"analytics.html\", gin.H{\n\t\t\"Title\":          \"Analytics\",\n\t\t\"CurrentPage\":    \"analytics\",\n\t\t\"Stats\":          stats,\n\t\t\"CurrencySymbol\": h.settingsService.GetCurrencySymbol(),\n\t\t\"DarkMode\":       h.settingsService.IsDarkModeEnabled(),\n\t})\n}\n\n// Calendar renders the calendar page with subscription renewal dates\nfunc (h *SubscriptionHandler) Calendar(c *gin.Context) {\n\t// Get all subscriptions with renewal dates\n\tsubscriptions, err := h.service.GetAll()\n\tif err != nil {\n\t\tc.HTML(http.StatusInternalServerError, \"error.html\", gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// Filter subscriptions with renewal dates and group by date\n\t// Create a simplified structure for JavaScript\n\ttype Event struct {\n\t\tName    string  `json:\"name\"`\n\t\tCost    float64 `json:\"cost\"`\n\t\tID      uint    `json:\"id\"`\n\t\tIconURL string  `json:\"icon_url\"`\n\t}\n\teventsByDate := make(map[string][]Event)\n\tfor _, sub := range subscriptions {\n\t\tif sub.RenewalDate != nil && sub.Status == \"Active\" {\n\t\t\tdateKey := sub.RenewalDate.Format(\"2006-01-02\")\n\t\t\teventsByDate[dateKey] = append(eventsByDate[dateKey], Event{\n\t\t\t\tName:    sub.Name,\n\t\t\t\tCost:    sub.Cost,\n\t\t\t\tID:      sub.ID,\n\t\t\t\tIconURL: sub.IconURL,\n\t\t\t})\n\t\t}\n\t}\n\n\t// Get current month/year or from query params\n\tnow := time.Now()\n\tyear := now.Year()\n\tmonth := int(now.Month())\n\n\tif y := c.Query(\"year\"); y != \"\" {\n\t\tif yInt, err := strconv.Atoi(y); err == nil {\n\t\t\tyear = yInt\n\t\t}\n\t}\n\tif m := c.Query(\"month\"); m != \"\" {\n\t\tif mInt, err := strconv.Atoi(m); err == nil {\n\t\t\tmonth = mInt\n\t\t}\n\t}\n\n\t// Validate month range\n\tif month < 1 {\n\t\tmonth = 1\n\t}\n\tif month > 12 {\n\t\tmonth = 12\n\t}\n\n\t// Calculate previous and next month\n\tfirstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)\n\tprevMonth := firstOfMonth.AddDate(0, -1, 0)\n\tnextMonth := firstOfMonth.AddDate(0, 1, 0)\n\n\t// Serialize events to JSON for JavaScript\n\teventsJSON, _ := json.Marshal(eventsByDate)\n\n\t// Prevent caching to ensure calendar updates when navigating months\n\tc.Header(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n\tc.Header(\"Pragma\", \"no-cache\")\n\tc.Header(\"Expires\", \"0\")\n\n\t// Build iCal subscription URL if enabled\n\ticalSubscriptionEnabled := h.settingsService.IsICalSubscriptionEnabled()\n\tvar icalSubscriptionURL string\n\tif icalSubscriptionEnabled {\n\t\ttoken, err := h.settingsService.GetOrGenerateICalToken()\n\t\tif err == nil {\n\t\t\ticalSubscriptionURL = buildBaseURL(c, h.settingsService.GetBaseURL()) + \"/ical/\" + token\n\t\t}\n\t}\n\n\tc.HTML(http.StatusOK, \"calendar.html\", gin.H{\n\t\t\"Title\":                   \"Calendar\",\n\t\t\"CurrentPage\":             \"calendar\",\n\t\t\"Year\":                    year,\n\t\t\"Month\":                   month,\n\t\t\"MonthName\":               firstOfMonth.Format(\"January 2006\"),\n\t\t\"EventsByDate\":            template.JS(string(eventsJSON)),\n\t\t\"FirstOfMonth\":            firstOfMonth,\n\t\t\"PrevMonth\":               prevMonth,\n\t\t\"NextMonth\":               nextMonth,\n\t\t\"CurrencySymbol\":          h.settingsService.GetCurrencySymbol(),\n\t\t\"DarkMode\":                h.settingsService.IsDarkModeEnabled(),\n\t\t\"ICalSubscriptionEnabled\": icalSubscriptionEnabled,\n\t\t\"ICalSubscriptionURL\":     icalSubscriptionURL,\n\t})\n}\n\n// generateICalContent generates iCal content for all active subscriptions\n// If forSubscription is true, adds subscription-friendly properties for calendar polling\nfunc (h *SubscriptionHandler) generateICalContent(forSubscription bool) (string, error) {\n\tsubscriptions, err := h.service.GetAll()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\ticalContent := \"BEGIN:VCALENDAR\\r\\n\"\n\ticalContent += \"VERSION:2.0\\r\\n\"\n\ticalContent += \"PRODID:-//SubTrackr//Subscription Renewals//EN\\r\\n\"\n\ticalContent += \"CALSCALE:GREGORIAN\\r\\n\"\n\ticalContent += \"METHOD:PUBLISH\\r\\n\"\n\n\tif forSubscription {\n\t\ticalContent += \"X-WR-CALNAME:SubTrackr Renewals\\r\\n\"\n\t\ticalContent += \"REFRESH-INTERVAL;VALUE=DURATION:PT1H\\r\\n\"\n\t\ticalContent += \"X-PUBLISHED-TTL:PT1H\\r\\n\"\n\t}\n\n\tnow := time.Now()\n\tfor _, sub := range subscriptions {\n\t\tif sub.RenewalDate != nil && sub.Status == \"Active\" {\n\t\t\tdtStart := sub.RenewalDate.Format(\"20060102T150000Z\")\n\t\t\tdtEnd := sub.RenewalDate.Add(1 * time.Hour).Format(\"20060102T150000Z\")\n\t\t\tdtStamp := now.Format(\"20060102T150000Z\")\n\t\t\tuid := fmt.Sprintf(\"subtrackr-%d-%d@subtrackr\", sub.ID, sub.RenewalDate.Unix())\n\n\t\t\tsummary := fmt.Sprintf(\"%s Renewal\", sub.Name)\n\t\t\tsubCurrencySymbol := h.settingsService.GetCurrencySymbol()\n\t\t\tif sub.OriginalCurrency != \"\" && sub.OriginalCurrency != h.settingsService.GetCurrency() {\n\t\t\t\tsubCurrencySymbol = service.CurrencySymbolForCode(sub.OriginalCurrency)\n\t\t\t}\n\t\t\tdescription := fmt.Sprintf(\"Subscription: %s\\\\nCost: %s%.2f\\\\nSchedule: %s\", sub.Name, subCurrencySymbol, sub.Cost, sub.DisplaySchedule())\n\t\t\tif sub.URL != \"\" {\n\t\t\t\tdescription += fmt.Sprintf(\"\\\\nURL: %s\", sub.URL)\n\t\t\t}\n\n\t\t\ticalContent += \"BEGIN:VEVENT\\r\\n\"\n\t\t\ticalContent += fmt.Sprintf(\"UID:%s\\r\\n\", uid)\n\t\t\ticalContent += fmt.Sprintf(\"DTSTAMP:%s\\r\\n\", dtStamp)\n\t\t\ticalContent += fmt.Sprintf(\"DTSTART:%s\\r\\n\", dtStart)\n\t\t\ticalContent += fmt.Sprintf(\"DTEND:%s\\r\\n\", dtEnd)\n\t\t\ticalContent += fmt.Sprintf(\"SUMMARY:%s\\r\\n\", summary)\n\t\t\ticalContent += fmt.Sprintf(\"DESCRIPTION:%s\\r\\n\", description)\n\t\t\ticalContent += \"STATUS:CONFIRMED\\r\\n\"\n\t\t\ticalContent += \"SEQUENCE:0\\r\\n\"\n\n\t\t\tinterval := sub.ScheduleInterval\n\t\t\tif interval < 1 {\n\t\t\t\tinterval = 1\n\t\t\t}\n\t\t\tswitch sub.Schedule {\n\t\t\tcase \"Daily\":\n\t\t\t\ticalContent += fmt.Sprintf(\"RRULE:FREQ=DAILY;INTERVAL=%d\\r\\n\", interval)\n\t\t\tcase \"Weekly\":\n\t\t\t\ticalContent += fmt.Sprintf(\"RRULE:FREQ=WEEKLY;INTERVAL=%d\\r\\n\", interval)\n\t\t\tcase \"Monthly\":\n\t\t\t\ticalContent += fmt.Sprintf(\"RRULE:FREQ=MONTHLY;INTERVAL=%d\\r\\n\", interval)\n\t\t\tcase \"Quarterly\":\n\t\t\t\ticalContent += fmt.Sprintf(\"RRULE:FREQ=MONTHLY;INTERVAL=%d\\r\\n\", 3*interval)\n\t\t\tcase \"Annual\":\n\t\t\t\ticalContent += fmt.Sprintf(\"RRULE:FREQ=YEARLY;INTERVAL=%d\\r\\n\", interval)\n\t\t\t}\n\n\t\t\ticalContent += \"END:VEVENT\\r\\n\"\n\t\t}\n\t}\n\n\ticalContent += \"END:VCALENDAR\\r\\n\"\n\treturn icalContent, nil\n}\n\n// ExportICal generates and downloads an iCal file with all subscription renewal dates\nfunc (h *SubscriptionHandler) ExportICal(c *gin.Context) {\n\ticalContent, err := h.generateICalContent(false)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.Header(\"Content-Type\", \"text/calendar; charset=utf-8\")\n\tc.Header(\"Content-Disposition\", `attachment; filename=\"subtrackr-renewals.ics\"`)\n\tc.Data(http.StatusOK, \"text/calendar; charset=utf-8\", []byte(icalContent))\n}\n\n// ServeICalSubscription serves iCal content for calendar subscription (public, token-validated)\nfunc (h *SubscriptionHandler) ServeICalSubscription(c *gin.Context) {\n\ttoken := c.Param(\"token\")\n\n\tif !h.settingsService.IsICalSubscriptionEnabled() {\n\t\tc.String(http.StatusNotFound, \"iCal subscription is not enabled\")\n\t\treturn\n\t}\n\n\tif !h.settingsService.ValidateICalToken(token) {\n\t\tc.String(http.StatusUnauthorized, \"Invalid token\")\n\t\treturn\n\t}\n\n\ticalContent, err := h.generateICalContent(true)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Failed to generate calendar\")\n\t\treturn\n\t}\n\n\tc.Header(\"Content-Type\", \"text/calendar; charset=utf-8\")\n\tc.Data(http.StatusOK, \"text/calendar; charset=utf-8\", []byte(icalContent))\n}\n\n// Settings renders the settings page\nfunc (h *SubscriptionHandler) Settings(c *gin.Context) {\n\t// Load SMTP config if available (without password)\n\tvar smtpConfig *models.SMTPConfig\n\tsmtpConfigured := false\n\tconfig, err := h.settingsService.GetSMTPConfig()\n\tif err == nil && config != nil {\n\t\t// Don't include password in template\n\t\tconfig.Password = \"\"\n\t\tsmtpConfig = config\n\t\tsmtpConfigured = true\n\t}\n\n\t// Load Pushover config if available\n\tvar pushoverConfig *models.PushoverConfig\n\tpushoverConfigured := false\n\tpushoverCfg, err := h.settingsService.GetPushoverConfig()\n\tif err == nil && pushoverCfg != nil {\n\t\tpushoverConfig = pushoverCfg\n\t\tpushoverConfigured = true\n\t}\n\n\t// Load Webhook config if available\n\tvar webhookConfig *models.WebhookConfig\n\twebhookConfigured := false\n\twebhookCfg, err := h.settingsService.GetWebhookConfig()\n\tif err == nil && webhookCfg != nil && webhookCfg.URL != \"\" {\n\t\twebhookConfig = webhookCfg\n\t\twebhookConfigured = true\n\t}\n\n\t// Get auth settings\n\tauthEnabled := h.settingsService.IsAuthEnabled()\n\tauthUsername, _ := h.settingsService.GetAuthUsername()\n\n\t// Build iCal subscription URL if enabled\n\ticalSubscriptionEnabled := h.settingsService.IsICalSubscriptionEnabled()\n\tvar icalSubscriptionURL string\n\tif icalSubscriptionEnabled {\n\t\ttoken, err := h.settingsService.GetOrGenerateICalToken()\n\t\tif err == nil {\n\t\t\ticalSubscriptionURL = buildBaseURL(c, h.settingsService.GetBaseURL()) + \"/ical/\" + token\n\t\t}\n\t}\n\n\tc.HTML(http.StatusOK, \"settings.html\", gin.H{\n\t\t\"Title\":                    \"Settings\",\n\t\t\"CurrentPage\":              \"settings\",\n\t\t\"Currency\":                 h.settingsService.GetCurrency(),\n\t\t\"CurrencySymbol\":           h.settingsService.GetCurrencySymbol(),\n\t\t\"RenewalReminders\":         h.settingsService.GetBoolSettingWithDefault(\"renewal_reminders\", false),\n\t\t\"HighCostAlerts\":           h.settingsService.GetBoolSettingWithDefault(\"high_cost_alerts\", true),\n\t\t\"PushoverConfig\":           pushoverConfig,\n\t\t\"PushoverConfigured\":       pushoverConfigured,\n\t\t\"HighCostThreshold\":        h.settingsService.GetFloatSettingWithDefault(\"high_cost_threshold\", 50.0),\n\t\t\"ReminderDays\":             h.settingsService.GetIntSettingWithDefault(\"reminder_days\", 7),\n\t\t\"CancellationReminders\":    h.settingsService.GetBoolSettingWithDefault(\"cancellation_reminders\", false),\n\t\t\"CancellationReminderDays\": h.settingsService.GetIntSettingWithDefault(\"cancellation_reminder_days\", 7),\n\t\t\"DarkMode\":                 h.settingsService.IsDarkModeEnabled(),\n\t\t\"Version\":                  version.GetVersion(),\n\t\t\"SMTPConfig\":               smtpConfig,\n\t\t\"SMTPConfigured\":           smtpConfigured,\n\t\t\"AuthEnabled\":              authEnabled,\n\t\t\"AuthUsername\":             authUsername,\n\t\t\"ICalSubscriptionEnabled\":  icalSubscriptionEnabled,\n\t\t\"ICalSubscriptionURL\":      icalSubscriptionURL,\n\t\t\"BaseURL\":                  h.settingsService.GetBaseURL(),\n\t\t\"Currencies\":               service.GetAvailableCurrencies(),\n\t\t\"DateFormat\":               h.settingsService.GetDateFormat(),\n\t\t\"WebhookConfig\":            webhookConfig,\n\t\t\"WebhookConfigured\":        webhookConfigured,\n\t})\n}\n\n// API endpoints for HTMX\n\n// GetSubscriptions returns subscriptions as HTML fragments\nfunc (h *SubscriptionHandler) GetSubscriptions(c *gin.Context) {\n\t// Get sort parameters from query string\n\tsortBy := c.DefaultQuery(\"sort\", \"created_at\")\n\torder := c.DefaultQuery(\"order\", \"desc\")\n\n\t// Get sorted subscriptions\n\tsubscriptions, err := h.service.GetAllSorted(sortBy, order)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// Enrich with currency conversion\n\tenrichedSubs := h.enrichWithCurrencyConversion(subscriptions)\n\n\tc.HTML(http.StatusOK, \"subscription-list.html\", gin.H{\n\t\t\"Subscriptions\":  enrichedSubs,\n\t\t\"CurrencySymbol\": h.settingsService.GetCurrencySymbol(),\n\t\t\"SortBy\":         sortBy,\n\t\t\"Order\":          order,\n\t\t\"GoDateFormat\":   h.settingsService.GetGoDateFormat(),\n\t})\n}\n\n// GetSubscriptionsAPI returns subscriptions as JSON for API calls\nfunc (h *SubscriptionHandler) GetSubscriptionsAPI(c *gin.Context) {\n\tsubscriptions, err := h.service.GetAll()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, subscriptions)\n}\n\n// CreateSubscription handles creating a new subscription\nfunc (h *SubscriptionHandler) CreateSubscription(c *gin.Context) {\n\tvar subscription models.Subscription\n\n\t// Parse form data\n\tsubscription.Name = c.PostForm(\"name\")\n\t// Parse category_id as uint\n\tif categoryIDStr := c.PostForm(\"category_id\"); categoryIDStr != \"\" {\n\t\tif categoryID, err := strconv.ParseUint(categoryIDStr, 10, 32); err == nil {\n\t\t\tsubscription.CategoryID = uint(categoryID)\n\t\t}\n\t}\n\tsubscription.Schedule = c.PostForm(\"schedule\")\n\tsubscription.ScheduleInterval = parseScheduleInterval(c.PostForm(\"schedule_interval\"))\n\tsubscription.Status = c.PostForm(\"status\")\n\tsubscription.OriginalCurrency = c.PostForm(\"original_currency\")\n\tif subscription.OriginalCurrency == \"\" {\n\t\tsubscription.OriginalCurrency = \"USD\"\n\t}\n\tsubscription.PaymentMethod = c.PostForm(\"payment_method\")\n\tsubscription.Account = c.PostForm(\"account\")\n\tsubscription.URL = c.PostForm(\"url\")\n\tsubscription.IconURL = c.PostForm(\"icon_url\")\n\tsubscription.Notes = c.PostForm(\"notes\")\n\tsubscription.Usage = c.PostForm(\"usage\")\n\n\t// Default reminders to enabled unless explicitly set to false\n\treminderVal := c.PostForm(\"reminder_enabled\")\n\tif reminderVal == \"\" {\n\t\tsubscription.ReminderEnabled = true\n\t} else {\n\t\tsubscription.ReminderEnabled = reminderVal == \"true\"\n\t}\n\n\t// Parse cost\n\tif costStr := c.PostForm(\"cost\"); costStr != \"\" {\n\t\tif cost, err := strconv.ParseFloat(costStr, 64); err == nil {\n\t\t\tsubscription.Cost = cost\n\t\t}\n\t}\n\n\t// Parse dates using helper function\n\tsubscription.StartDate = parseDatePtr(c.PostForm(\"start_date\"))\n\tsubscription.RenewalDate = parseDatePtr(c.PostForm(\"renewal_date\"))\n\tsubscription.CancellationDate = parseDatePtr(c.PostForm(\"cancellation_date\"))\n\n\t// Fetch logo synchronously before creation if URL is provided and icon_url is empty\n\th.fetchAndSetLogo(&subscription)\n\n\t// Create subscription\n\tcreated, err := h.service.Create(&subscription)\n\tif err != nil {\n\t\t// Log the error for debugging\n\t\tlog.Printf(\"Failed to create subscription: %v\", err)\n\t\tlog.Printf(\"Subscription data: Name=%s, CategoryID=%d, Status=%s, Schedule=%s\",\n\t\t\tsubscription.Name, subscription.CategoryID, subscription.Status, subscription.Schedule)\n\n\t\tif c.GetHeader(\"HX-Request\") != \"\" {\n\t\t\tc.Header(\"HX-Retarget\", \"#form-errors\")\n\t\t\tc.HTML(http.StatusBadRequest, \"form-errors.html\", gin.H{\n\t\t\t\t\"Error\": err.Error(),\n\t\t\t})\n\t\t} else {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\t}\n\t\treturn\n\t}\n\n\t// Send high-cost alert email and Pushover notification if applicable\n\tif h.isHighCostWithCurrency(created) {\n\t\t// Reload subscription with category for email template\n\t\tsubscriptionWithCategory, err := h.service.GetByID(created.ID)\n\t\tif err == nil && subscriptionWithCategory != nil {\n\t\t\t// Send email notification\n\t\t\tif err := h.emailService.SendHighCostAlert(subscriptionWithCategory); err != nil {\n\t\t\t\t// Log error but don't fail the request\n\t\t\t\tlog.Printf(\"Failed to send high-cost alert email: %v\", err)\n\t\t\t}\n\t\t\t// Send Pushover notification\n\t\t\tif err := h.pushoverService.SendHighCostAlert(subscriptionWithCategory); err != nil {\n\t\t\t\t// Log error but don't fail the request\n\t\t\t\tlog.Printf(\"Failed to send high-cost alert Pushover notification: %v\", err)\n\t\t\t}\n\t\t\t// Send Webhook notification\n\t\t\tif err := h.webhookService.SendHighCostAlert(subscriptionWithCategory); err != nil {\n\t\t\t\tlog.Printf(\"Failed to send high-cost alert webhook: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif c.GetHeader(\"HX-Request\") != \"\" {\n\t\tc.Header(\"HX-Refresh\", \"true\")\n\t\tc.Status(http.StatusCreated)\n\t} else {\n\t\tc.JSON(http.StatusCreated, created)\n\t}\n}\n\n// GetSubscription returns a single subscription\nfunc (h *SubscriptionHandler) GetSubscription(c *gin.Context) {\n\tid, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid ID\"})\n\t\treturn\n\t}\n\n\tsubscription, err := h.service.GetByID(uint(id))\n\tif err != nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"Subscription not found\"})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, subscription)\n}\n\n// UpdateSubscription handles updating an existing subscription\nfunc (h *SubscriptionHandler) UpdateSubscription(c *gin.Context) {\n\tid, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid ID\"})\n\t\treturn\n\t}\n\n\t// Fetch existing subscription first — only overwrite fields actually sent in the request\n\texisting, err := h.service.GetByID(uint(id))\n\tif err != nil || existing == nil {\n\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"Subscription not found\"})\n\t\treturn\n\t}\n\n\twasHighCost := h.isHighCostWithCurrency(existing)\n\n\t// Merge form data: only update fields that were actually submitted\n\tif val, ok := c.GetPostForm(\"name\"); ok {\n\t\texisting.Name = val\n\t}\n\tif val, ok := c.GetPostForm(\"category_id\"); ok && val != \"\" {\n\t\tif categoryID, err := strconv.ParseUint(val, 10, 32); err == nil {\n\t\t\texisting.CategoryID = uint(categoryID)\n\t\t}\n\t}\n\tif val, ok := c.GetPostForm(\"schedule\"); ok {\n\t\texisting.Schedule = val\n\t}\n\tif val, ok := c.GetPostForm(\"schedule_interval\"); ok {\n\t\texisting.ScheduleInterval = parseScheduleInterval(val)\n\t}\n\tif val, ok := c.GetPostForm(\"status\"); ok {\n\t\texisting.Status = val\n\t}\n\tif val, ok := c.GetPostForm(\"original_currency\"); ok {\n\t\tif val == \"\" {\n\t\t\texisting.OriginalCurrency = \"USD\"\n\t\t} else {\n\t\t\texisting.OriginalCurrency = val\n\t\t}\n\t}\n\tif val, ok := c.GetPostForm(\"payment_method\"); ok {\n\t\texisting.PaymentMethod = val\n\t}\n\tif val, ok := c.GetPostForm(\"account\"); ok {\n\t\texisting.Account = val\n\t}\n\n\t// Track URL changes for logo refresh\n\toldURL := existing.URL\n\tif val, ok := c.GetPostForm(\"url\"); ok {\n\t\texisting.URL = val\n\t}\n\turlChanged := existing.URL != oldURL\n\n\tif val, ok := c.GetPostForm(\"icon_url\"); ok && val != \"\" {\n\t\texisting.IconURL = val\n\t} else if urlChanged {\n\t\t// URL changed but no explicit icon — re-fetch\n\t\texisting.IconURL = \"\"\n\t}\n\n\tif val, ok := c.GetPostForm(\"notes\"); ok {\n\t\texisting.Notes = val\n\t}\n\tif val, ok := c.GetPostForm(\"usage\"); ok {\n\t\texisting.Usage = val\n\t}\n\tif val, ok := c.GetPostForm(\"reminder_enabled\"); ok {\n\t\texisting.ReminderEnabled = val == \"true\"\n\t}\n\tif val, ok := c.GetPostForm(\"cost\"); ok && val != \"\" {\n\t\tif cost, err := strconv.ParseFloat(val, 64); err == nil {\n\t\t\texisting.Cost = cost\n\t\t}\n\t}\n\n\t// Parse dates — only update if the field was submitted\n\tif val, ok := c.GetPostForm(\"start_date\"); ok {\n\t\texisting.StartDate = parseDatePtr(val)\n\t}\n\tif val, ok := c.GetPostForm(\"renewal_date\"); ok {\n\t\texisting.RenewalDate = parseDatePtr(val)\n\t}\n\tif val, ok := c.GetPostForm(\"cancellation_date\"); ok {\n\t\texisting.CancellationDate = parseDatePtr(val)\n\t}\n\n\t// Fetch new logo if URL changed or URL is set but no icon\n\tif urlChanged || (existing.URL != \"\" && existing.IconURL == \"\") {\n\t\th.fetchAndSetLogo(existing)\n\t}\n\n\t// Update subscription\n\tupdated, err := h.service.Update(uint(id), existing)\n\tif err != nil {\n\t\tc.Header(\"HX-Retarget\", \"#form-errors\")\n\t\tc.HTML(http.StatusBadRequest, \"form-errors.html\", gin.H{\n\t\t\t\"Error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Send high-cost alert email and Pushover notification if subscription became high-cost (wasn't before, but is now)\n\tif updated != nil && !wasHighCost && h.isHighCostWithCurrency(updated) {\n\t\t// Reload subscription with category for email template\n\t\tsubscriptionWithCategory, err := h.service.GetByID(updated.ID)\n\t\tif err == nil && subscriptionWithCategory != nil {\n\t\t\t// Send email notification\n\t\t\tif err := h.emailService.SendHighCostAlert(subscriptionWithCategory); err != nil {\n\t\t\t\t// Log error but don't fail the request\n\t\t\t\tlog.Printf(\"Failed to send high-cost alert email: %v\", err)\n\t\t\t}\n\t\t\t// Send Pushover notification\n\t\t\tif err := h.pushoverService.SendHighCostAlert(subscriptionWithCategory); err != nil {\n\t\t\t\t// Log error but don't fail the request\n\t\t\t\tlog.Printf(\"Failed to send high-cost alert Pushover notification: %v\", err)\n\t\t\t}\n\t\t\t// Send Webhook notification\n\t\t\tif err := h.webhookService.SendHighCostAlert(subscriptionWithCategory); err != nil {\n\t\t\t\tlog.Printf(\"Failed to send high-cost alert webhook: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Return success response that triggers a page refresh\n\tc.Header(\"HX-Refresh\", \"true\")\n\tc.Status(http.StatusOK)\n}\n\n// DeleteSubscription handles deleting a subscription\nfunc (h *SubscriptionHandler) DeleteSubscription(c *gin.Context) {\n\tid, err := strconv.ParseUint(c.Param(\"id\"), 10, 32)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid ID\"})\n\t\treturn\n\t}\n\n\terr = h.service.Delete(uint(id))\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// Return success response that triggers a page refresh\n\tc.Header(\"HX-Refresh\", \"true\")\n\tc.Status(http.StatusOK)\n}\n\n// GetStats returns current statistics\nfunc (h *SubscriptionHandler) GetStats(c *gin.Context) {\n\tstats, err := h.service.GetStats()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, stats)\n}\n\n// GetSubscriptionForm returns the subscription form (for add/edit)\nfunc (h *SubscriptionHandler) GetSubscriptionForm(c *gin.Context) {\n\tvar subscription *models.Subscription\n\tisEdit := false\n\n\t// Check if this is an edit form\n\tif idStr := c.Param(\"id\"); idStr != \"\" {\n\t\tid, err := strconv.ParseUint(idStr, 10, 32)\n\t\tif err == nil {\n\t\t\tsub, err := h.service.GetByID(uint(id))\n\t\t\tif err == nil {\n\t\t\t\tsubscription = sub\n\t\t\t\tisEdit = true\n\t\t\t}\n\t\t}\n\t}\n\n\tcategories, err := h.service.GetAllCategories()\n\tif err != nil {\n\t\tcategories = []models.Category{}\n\t}\n\n\tc.HTML(http.StatusOK, \"subscription-form.html\", gin.H{\n\t\t\"Subscription\":   subscription,\n\t\t\"IsEdit\":         isEdit,\n\t\t\"CurrencySymbol\": h.settingsService.GetCurrencySymbol(),\n\t\t\"Categories\":     categories,\n\t\t\"Currencies\":     service.GetAvailableCurrencies(),\n\t})\n}\n\n// ExportCSV exports all subscriptions as CSV\nfunc (h *SubscriptionHandler) ExportCSV(c *gin.Context) {\n\tsubscriptions, err := h.service.GetAll()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.Header(\"Content-Type\", \"text/csv\")\n\tc.Header(\"Content-Disposition\", \"attachment; filename=subscriptions.csv\")\n\n\twriter := csv.NewWriter(c.Writer)\n\tdefer writer.Flush()\n\n\t// Write CSV header\n\theader := []string{\"ID\", \"Name\", \"Category\", \"Cost\", \"Currency\", \"Schedule\", \"Schedule Interval\", \"Status\", \"Payment Method\", \"Account\", \"Start Date\", \"Renewal Date\", \"Cancellation Date\", \"URL\", \"Notes\", \"Usage\", \"Created At\"}\n\twriter.Write(header)\n\n\t// Write subscription data\n\tfor _, sub := range subscriptions {\n\t\tcategoryName := \"\"\n\t\tif sub.Category.Name != \"\" {\n\t\t\tcategoryName = sub.Category.Name\n\t\t}\n\t\tcurrency := sub.OriginalCurrency\n\t\tif currency == \"\" {\n\t\t\tcurrency = h.settingsService.GetCurrency()\n\t\t}\n\t\trecord := []string{\n\t\t\tfmt.Sprintf(\"%d\", sub.ID),\n\t\t\tsub.Name,\n\t\t\tcategoryName,\n\t\t\tfmt.Sprintf(\"%.2f\", sub.Cost),\n\t\t\tcurrency,\n\t\t\tsub.DisplaySchedule(),\n\t\t\tfmt.Sprintf(\"%d\", sub.ScheduleInterval),\n\t\t\tsub.Status,\n\t\t\tsub.PaymentMethod,\n\t\t\tsub.Account,\n\t\t\tformatDate(sub.StartDate),\n\t\t\tformatDate(sub.RenewalDate),\n\t\t\tformatDate(sub.CancellationDate),\n\t\t\tsub.URL,\n\t\t\tsub.Notes,\n\t\t\tsub.Usage,\n\t\t\tsub.CreatedAt.Format(\"2006-01-02 15:04:05\"),\n\t\t}\n\t\twriter.Write(record)\n\t}\n}\n\n// ExportJSON exports all subscriptions as JSON\nfunc (h *SubscriptionHandler) ExportJSON(c *gin.Context) {\n\tsubscriptions, err := h.service.GetAll()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tc.Header(\"Content-Type\", \"application/json\")\n\tc.Header(\"Content-Disposition\", \"attachment; filename=subscriptions.json\")\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"subscriptions\": subscriptions,\n\t\t\"exported_at\":   time.Now(),\n\t\t\"total_count\":   len(subscriptions),\n\t})\n}\n\n// BackupData creates a complete backup of all data\nfunc (h *SubscriptionHandler) BackupData(c *gin.Context) {\n\tsubscriptions, err := h.service.GetAll()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tstats, err := h.service.GetStats()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tbackup := gin.H{\n\t\t\"version\":       \"1.0\",\n\t\t\"backup_date\":   time.Now(),\n\t\t\"subscriptions\": subscriptions,\n\t\t\"stats\":         stats,\n\t\t\"total_count\":   len(subscriptions),\n\t}\n\n\tc.Header(\"Content-Type\", \"application/json\")\n\tc.Header(\"Content-Disposition\", \"attachment; filename=subtrackr-backup.json\")\n\tc.JSON(http.StatusOK, backup)\n}\n\n// RestoreData imports subscriptions from a backup JSON file\nfunc (h *SubscriptionHandler) RestoreData(c *gin.Context) {\n\tc.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 10<<20) // 10 MB limit\n\n\tfile, _, err := c.Request.FormFile(\"backup_file\")\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"No backup file provided or file too large (max 10 MB)\"})\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\tvar backup struct {\n\t\tVersion       string                `json:\"version\"`\n\t\tSubscriptions []models.Subscription `json:\"subscriptions\"`\n\t}\n\n\tdecoder := json.NewDecoder(file)\n\tif err := decoder.Decode(&backup); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid backup file format\"})\n\t\treturn\n\t}\n\n\tif len(backup.Subscriptions) == 0 {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Backup file contains no subscriptions\"})\n\t\treturn\n\t}\n\n\tmode := c.PostForm(\"mode\")\n\tif mode == \"\" {\n\t\tmode = \"replace\"\n\t}\n\tif mode != \"replace\" && mode != \"merge\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid mode, must be 'replace' or 'merge'\"})\n\t\treturn\n\t}\n\n\tif mode == \"replace\" {\n\t\texisting, err := h.service.GetAll()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"Failed to fetch existing data\"})\n\t\t\treturn\n\t\t}\n\t\tfor _, sub := range existing {\n\t\t\tif err := h.service.Delete(sub.ID); err != nil {\n\t\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"Failed to clear existing data: %v\", err)})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tcategoryMap := make(map[string]uint)\n\tcategories, _ := h.categoryService.GetAll()\n\tfor _, cat := range categories {\n\t\tcategoryMap[cat.Name] = cat.ID\n\t}\n\n\timported := 0\n\tvar errors []string\n\tfor _, sub := range backup.Subscriptions {\n\t\tif sub.Category.Name != \"\" {\n\t\t\tif catID, ok := categoryMap[sub.Category.Name]; ok {\n\t\t\t\tsub.CategoryID = catID\n\t\t\t} else {\n\t\t\t\tnewCat := &models.Category{Name: sub.Category.Name}\n\t\t\t\tcreated, err := h.categoryService.Create(newCat)\n\t\t\t\tif err == nil {\n\t\t\t\t\tcategoryMap[created.Name] = created.ID\n\t\t\t\t\tsub.CategoryID = created.ID\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tsub.ID = 0\n\t\tsub.Category = models.Category{}\n\t\tsub.CreatedAt = time.Time{}\n\t\tsub.UpdatedAt = time.Time{}\n\n\t\t_, err := h.service.Create(&sub)\n\t\tif err != nil {\n\t\t\terrors = append(errors, fmt.Sprintf(\"Failed to import '%s': %v\", sub.Name, err))\n\t\t\tcontinue\n\t\t}\n\t\timported++\n\t}\n\n\tresult := gin.H{\n\t\t\"message\":        fmt.Sprintf(\"Successfully imported %d subscriptions\", imported),\n\t\t\"imported_count\": imported,\n\t\t\"total_in_file\":  len(backup.Subscriptions),\n\t\t\"mode\":           mode,\n\t}\n\tif len(errors) > 0 {\n\t\tresult[\"errors\"] = errors\n\t\tresult[\"partial_success\"] = true\n\t\tc.JSON(http.StatusMultiStatus, result)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, result)\n}\n\n// ClearAllData removes all subscription data\nfunc (h *SubscriptionHandler) ClearAllData(c *gin.Context) {\n\tsubscriptions, err := h.service.GetAll()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// Delete all subscriptions\n\tfor _, sub := range subscriptions {\n\t\terr := h.service.Delete(sub.ID)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": fmt.Sprintf(\"Failed to delete subscription %d: %v\", sub.ID, err)})\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"message\":       \"All subscription data has been cleared\",\n\t\t\"deleted_count\": len(subscriptions),\n\t})\n}\n\n// Helper function to format currency\nfunc formatCurrency(amount float64) string {\n\treturn fmt.Sprintf(\"$%.2f\", amount)\n}\n\n// Helper function to format date pointers\nfunc formatDate(date *time.Time) string {\n\tif date == nil {\n\t\treturn \"\"\n\t}\n\treturn date.Format(\"2006-01-02\")\n}\n"
  },
  {
    "path": "internal/handlers/subscription_test.go",
    "content": "package handlers\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestParseDatePtr(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected *time.Time\n\t\tvalid    bool\n\t}{\n\t\t{\n\t\t\tname:     \"Valid date string\",\n\t\t\tinput:    \"2024-01-15\",\n\t\t\texpected: timePtr(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)),\n\t\t\tvalid:    true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Valid date with leap year\",\n\t\t\tinput:    \"2024-02-29\",\n\t\t\texpected: timePtr(time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)),\n\t\t\tvalid:    true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Valid date at year boundary\",\n\t\t\tinput:    \"2024-12-31\",\n\t\t\texpected: timePtr(time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)),\n\t\t\tvalid:    true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Empty string\",\n\t\t\tinput:    \"\",\n\t\t\texpected: nil,\n\t\t\tvalid:    true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid date format - wrong separator\",\n\t\t\tinput:    \"2024/01/15\",\n\t\t\texpected: nil,\n\t\t\tvalid:    false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid date format - wrong order\",\n\t\t\tinput:    \"15-01-2024\",\n\t\t\texpected: nil,\n\t\t\tvalid:    false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid date - invalid month\",\n\t\t\tinput:    \"2024-13-15\",\n\t\t\texpected: nil,\n\t\t\tvalid:    false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid date - invalid day\",\n\t\t\tinput:    \"2024-02-30\",\n\t\t\texpected: nil,\n\t\t\tvalid:    false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid date - non-leap year Feb 29\",\n\t\t\tinput:    \"2025-02-29\",\n\t\t\texpected: nil,\n\t\t\tvalid:    false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid date - text\",\n\t\t\tinput:    \"not-a-date\",\n\t\t\texpected: nil,\n\t\t\tvalid:    false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid date - partial\",\n\t\t\tinput:    \"2024-01\",\n\t\t\texpected: nil,\n\t\t\tvalid:    false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := parseDatePtr(tt.input)\n\n\t\t\tif tt.expected == nil {\n\t\t\t\tassert.Nil(t, result, \"Expected nil for invalid/empty input\")\n\t\t\t} else {\n\t\t\t\tassert.NotNil(t, result, \"Expected non-nil result for valid input\")\n\t\t\t\tif result != nil {\n\t\t\t\t\t// Compare date components only (Year, Month, Day) as parseDatePtr returns UTC dates with zero time components\n\t\t\t\t\tassert.Equal(t, tt.expected.Year(), result.Year(), \"Year should match\")\n\t\t\t\t\tassert.Equal(t, tt.expected.Month(), result.Month(), \"Month should match\")\n\t\t\t\t\tassert.Equal(t, tt.expected.Day(), result.Day(), \"Day should match\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Helper function to create time pointer\nfunc timePtr(t time.Time) *time.Time {\n\treturn &t\n}\n\n"
  },
  {
    "path": "internal/handlers/url.go",
    "content": "package handlers\n\nimport (\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// buildBaseURL returns the external base URL for the application.\n// Priority: configured base URL > X-Forwarded headers > request Host.\nfunc buildBaseURL(c *gin.Context, configuredBaseURL string) string {\n\tif configuredBaseURL != \"\" {\n\t\treturn strings.TrimRight(configuredBaseURL, \"/\")\n\t}\n\n\tscheme := \"http\"\n\thost := c.Request.Host\n\n\t// Check X-Forwarded-Proto / X-Forwarded-Host (reverse proxy headers)\n\tif fwdProto := c.GetHeader(\"X-Forwarded-Proto\"); fwdProto != \"\" {\n\t\tscheme = fwdProto\n\t} else if c.Request.TLS != nil {\n\t\tscheme = \"https\"\n\t}\n\n\tif fwdHost := c.GetHeader(\"X-Forwarded-Host\"); fwdHost != \"\" {\n\t\thost = fwdHost\n\t}\n\n\treturn scheme + \"://\" + host\n}\n"
  },
  {
    "path": "internal/middleware/auth.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"subtrackr/internal/service\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// AuthMiddleware creates middleware that requires authentication\nfunc AuthMiddleware(settingsService *service.SettingsService, sessionService *service.SessionService) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// Check if auth is enabled\n\t\tif !settingsService.IsAuthEnabled() {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Skip auth for certain routes\n\t\tpath := c.Request.URL.Path\n\t\tif isPublicRoute(path) {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Check if user is authenticated\n\t\tif !sessionService.IsAuthenticated(c.Request) {\n\t\t\t// Redirect to login page for HTML requests\n\t\t\tif isHTMLRequest(c.Request) {\n\t\t\t\tc.Redirect(http.StatusFound, \"/login?redirect=\"+url.QueryEscape(c.Request.URL.Path))\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Return 401 for API requests\n\t\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"Authentication required\"})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// isPublicRoute checks if a route should be accessible without authentication\nfunc isPublicRoute(path string) bool {\n\tpublicRoutes := []string{\n\t\t\"/login\",\n\t\t\"/forgot-password\",\n\t\t\"/reset-password\",\n\t\t\"/api/auth/login\",\n\t\t\"/api/auth/logout\",\n\t\t\"/api/auth/forgot-password\",\n\t\t\"/api/auth/reset-password\",\n\t\t\"/static/\",\n\t\t\"/favicon.ico\",\n\t\t\"/healthz\",\n\t\t\"/ical/\",\n\t}\n\n\t// API v1 routes use API keys, not session auth\n\tif strings.HasPrefix(path, \"/api/v1/\") {\n\t\treturn true\n\t}\n\n\tfor _, route := range publicRoutes {\n\t\tif strings.HasPrefix(path, route) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// isHTMLRequest checks if the request is for HTML content\nfunc isHTMLRequest(r *http.Request) bool {\n\taccept := r.Header.Get(\"Accept\")\n\treturn strings.Contains(accept, \"text/html\") || accept == \"\"\n}\n\n// APIKeyAuth creates middleware that requires API key authentication\nfunc APIKeyAuth(settingsService *service.SettingsService) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tapiKey := c.GetHeader(\"X-API-Key\")\n\n\t\t// Also check Authorization: Bearer header\n\t\tif apiKey == \"\" {\n\t\t\tauthHeader := c.GetHeader(\"Authorization\")\n\t\t\tif strings.HasPrefix(authHeader, \"Bearer \") {\n\t\t\t\tapiKey = strings.TrimPrefix(authHeader, \"Bearer \")\n\t\t\t}\n\t\t}\n\n\t\tif apiKey == \"\" {\n\t\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"API key required\"})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// Validate API key\n\t\t_, err := settingsService.ValidateAPIKey(apiKey)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"Invalid API key\"})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "internal/models/category.go",
    "content": "package models\n\nimport \"time\"\n\n// Category represents a subscription category\ntype Category struct {\n\tID        uint      `json:\"id\" gorm:\"primaryKey\"`\n\tName      string    `json:\"name\" gorm:\"uniqueIndex;not null\"`\n\tCreatedAt time.Time `json:\"created_at\" gorm:\"autoCreateTime\"`\n\tUpdatedAt time.Time `json:\"updated_at\" gorm:\"autoUpdateTime\"`\n}\n"
  },
  {
    "path": "internal/models/date_migration_audit.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\n// DateMigrationLog tracks changes made during date calculation migrations\ntype DateMigrationLog struct {\n\tID             uint      `json:\"id\" gorm:\"primaryKey\"`\n\tSubscriptionID uint      `json:\"subscription_id\" gorm:\"not null\"`\n\tOldVersion     int       `json:\"old_version\" gorm:\"not null\"`\n\tNewVersion     int       `json:\"new_version\" gorm:\"not null\"`\n\tOldRenewalDate *time.Time `json:\"old_renewal_date\"`\n\tNewRenewalDate *time.Time `json:\"new_renewal_date\"`\n\tMigrationReason string    `json:\"migration_reason\" gorm:\"size:255\"`\n\tMigratedAt     time.Time `json:\"migrated_at\" gorm:\"autoCreateTime\"`\n}\n\n// DateMigrationSafetyCheck provides utilities for safe date calculation migrations\ntype DateMigrationSafetyCheck struct {\n\tdb *gorm.DB\n}\n\n// NewDateMigrationSafetyCheck creates a new migration safety checker\nfunc NewDateMigrationSafetyCheck(db *gorm.DB) *DateMigrationSafetyCheck {\n\treturn &DateMigrationSafetyCheck{db: db}\n}\n\n// MigrateSubscriptionToV2 safely migrates a single subscription to V2 date calculation\nfunc (dmsc *DateMigrationSafetyCheck) MigrateSubscriptionToV2(subscriptionID uint, reason string) error {\n\t// Load the subscription\n\tvar sub Subscription\n\tif err := dmsc.db.First(&sub, subscriptionID).Error; err != nil {\n\t\treturn err\n\t}\n\n\t// Skip if already V2\n\tif sub.DateCalculationVersion == 2 {\n\t\treturn nil\n\t}\n\n\t// Store original values for audit\n\toldVersion := sub.DateCalculationVersion\n\toldRenewalDate := sub.RenewalDate\n\n\t// Calculate with V2\n\tsub.DateCalculationVersion = 2\n\tsub.calculateNextRenewalDate()\n\n\t// Create audit log entry\n\tauditLog := DateMigrationLog{\n\t\tSubscriptionID:  subscriptionID,\n\t\tOldVersion:      oldVersion,\n\t\tNewVersion:      2,\n\t\tOldRenewalDate:  oldRenewalDate,\n\t\tNewRenewalDate:  sub.RenewalDate,\n\t\tMigrationReason: reason,\n\t}\n\n\t// Save both subscription and audit log in transaction\n\treturn dmsc.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Save(&sub).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn tx.Create(&auditLog).Error\n\t})\n}\n\n// CompareCalculationVersions compares V1 and V2 calculations without changing data\nfunc (dmsc *DateMigrationSafetyCheck) CompareCalculationVersions(subscriptionID uint) (V1Date, V2Date *time.Time, err error) {\n\tvar sub Subscription\n\tif err = dmsc.db.First(&sub, subscriptionID).Error; err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Calculate V1\n\tsubV1 := sub\n\tsubV1.DateCalculationVersion = 1\n\tsubV1.calculateNextRenewalDate()\n\tV1Date = subV1.RenewalDate\n\n\t// Calculate V2\n\tsubV2 := sub\n\tsubV2.DateCalculationVersion = 2\n\tsubV2.calculateNextRenewalDate()\n\tV2Date = subV2.RenewalDate\n\n\treturn V1Date, V2Date, nil\n}\n\n// BatchMigrateToV2WithAudit migrates all subscriptions to V2 with comprehensive auditing\nfunc (dmsc *DateMigrationSafetyCheck) BatchMigrateToV2WithAudit(dryRun bool) error {\n\tvar subscriptions []Subscription\n\tif err := dmsc.db.Where(\"date_calculation_version = 1\").Find(&subscriptions).Error; err != nil {\n\t\treturn err\n\t}\n\n\tfor _, sub := range subscriptions {\n\t\t// Compare versions first\n\t\tv1Date, v2Date, err := dmsc.CompareCalculationVersions(sub.ID)\n\t\tif err != nil {\n\t\t\tcontinue // Skip on error\n\t\t}\n\n\t\t// Log significant differences\n\t\tif v1Date != nil && v2Date != nil {\n\t\t\tdiff := v2Date.Sub(*v1Date).Abs()\n\t\t\tif diff > 7*24*time.Hour { // More than 7 days difference\n\t\t\t\tauditLog := DateMigrationLog{\n\t\t\t\t\tSubscriptionID:  sub.ID,\n\t\t\t\t\tOldVersion:      1,\n\t\t\t\t\tNewVersion:      2,\n\t\t\t\t\tOldRenewalDate:  v1Date,\n\t\t\t\t\tNewRenewalDate:  v2Date,\n\t\t\t\t\tMigrationReason: \"Batch migration - significant difference detected\",\n\t\t\t\t}\n\t\t\t\tdmsc.db.Create(&auditLog)\n\t\t\t}\n\t\t}\n\n\t\t// Perform actual migration if not dry run\n\t\tif !dryRun {\n\t\t\tdmsc.MigrateSubscriptionToV2(sub.ID, \"Batch migration to V2\")\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// RollbackSubscriptionToV1 rolls back a subscription to V1 calculation (emergency rollback)\nfunc (dmsc *DateMigrationSafetyCheck) RollbackSubscriptionToV1(subscriptionID uint, reason string) error {\n\t// Load the subscription\n\tvar sub Subscription\n\tif err := dmsc.db.First(&sub, subscriptionID).Error; err != nil {\n\t\treturn err\n\t}\n\n\t// Skip if already V1\n\tif sub.DateCalculationVersion == 1 {\n\t\treturn nil\n\t}\n\n\t// Find the original audit log to restore previous renewal date\n\tvar auditLog DateMigrationLog\n\terr := dmsc.db.Where(\"subscription_id = ? AND new_version = ?\", subscriptionID, 2).\n\t\tOrder(\"migrated_at DESC\").First(&auditLog).Error\n\n\toldRenewalDate := sub.RenewalDate\n\n\tif err == nil && auditLog.OldRenewalDate != nil {\n\t\t// Restore original renewal date if we have audit record\n\t\tsub.RenewalDate = auditLog.OldRenewalDate\n\t} else {\n\t\t// Recalculate with V1 if no audit record\n\t\tsub.DateCalculationVersion = 1\n\t\tsub.calculateNextRenewalDate()\n\t}\n\n\tsub.DateCalculationVersion = 1\n\n\t// Create rollback audit log\n\trollbackLog := DateMigrationLog{\n\t\tSubscriptionID:  subscriptionID,\n\t\tOldVersion:      2,\n\t\tNewVersion:      1,\n\t\tOldRenewalDate:  oldRenewalDate,\n\t\tNewRenewalDate:  sub.RenewalDate,\n\t\tMigrationReason: \"ROLLBACK: \" + reason,\n\t}\n\n\t// Save both subscription and audit log in transaction\n\treturn dmsc.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Save(&sub).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn tx.Create(&rollbackLog).Error\n\t})\n}\n\n// GetMigrationStats returns statistics about date calculation migrations\nfunc (dmsc *DateMigrationSafetyCheck) GetMigrationStats() (map[string]interface{}, error) {\n\tstats := make(map[string]interface{})\n\n\t// Count subscriptions by version\n\tvar v1Count, v2Count int64\n\tdmsc.db.Model(&Subscription{}).Where(\"date_calculation_version = 1\").Count(&v1Count)\n\tdmsc.db.Model(&Subscription{}).Where(\"date_calculation_version = 2\").Count(&v2Count)\n\n\t// Count audit logs\n\tvar auditCount int64\n\tdmsc.db.Model(&DateMigrationLog{}).Count(&auditCount)\n\n\t// Count rollbacks\n\tvar rollbackCount int64\n\tdmsc.db.Model(&DateMigrationLog{}).Where(\"migration_reason LIKE 'ROLLBACK:%'\").Count(&rollbackCount)\n\n\tstats[\"v1_subscriptions\"] = v1Count\n\tstats[\"v2_subscriptions\"] = v2Count\n\tstats[\"total_migrations\"] = auditCount\n\tstats[\"rollbacks\"] = rollbackCount\n\n\treturn stats, nil\n}"
  },
  {
    "path": "internal/models/date_migration_audit_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc setupAuditTestDB(t *testing.T) *gorm.DB {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\n\t// Migrate the schema\n\terr = db.AutoMigrate(&Subscription{}, &DateMigrationLog{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate schema: %v\", err)\n\t}\n\n\treturn db\n}\n\nfunc TestNewDateMigrationSafetyCheck(t *testing.T) {\n\tdb := setupAuditTestDB(t)\n\n\tsafety := NewDateMigrationSafetyCheck(db)\n\n\tassert.NotNil(t, safety, \"SafetyCheck should not be nil\")\n\tassert.Equal(t, db, safety.db, \"Database should be set correctly\")\n}\n\nfunc TestCompareCalculationVersions(t *testing.T) {\n\tdb := setupAuditTestDB(t)\n\tsafety := NewDateMigrationSafetyCheck(db)\n\n\t// Create a test subscription\n\tstartDate := time.Date(2025, 1, 31, 10, 0, 0, 0, time.UTC)\n\tsub := &Subscription{\n\t\tName:      \"Test Subscription\",\n\t\tCost:      15.99,\n\t\tSchedule:  \"Monthly\",\n\t\tStatus:    \"Active\",\n\t\tStartDate: &startDate,\n\t\tDateCalculationVersion: 1,\n\t}\n\n\terr := db.Create(sub).Error\n\tassert.NoError(t, err, \"Should create test subscription\")\n\n\t// Compare V1 vs V2 calculations\n\tv1Date, v2Date, err := safety.CompareCalculationVersions(sub.ID)\n\tassert.NoError(t, err, \"Should compare calculations successfully\")\n\n\tassert.NotNil(t, v1Date, \"V1 calculation should return a date\")\n\tassert.NotNil(t, v2Date, \"V2 calculation should return a date\")\n\n\t// Both should be in the future\n\tassert.True(t, v1Date.After(time.Now()), \"V1 date should be in future\")\n\tassert.True(t, v2Date.After(time.Now()), \"V2 date should be in future\")\n}\n\nfunc TestGetMigrationStats(t *testing.T) {\n\tdb := setupAuditTestDB(t)\n\tsafety := NewDateMigrationSafetyCheck(db)\n\n\t// Create test subscriptions with different versions\n\tsubs := []Subscription{\n\t\t{Name: \"V1 Sub 1\", Cost: 10, Schedule: \"Monthly\", Status: \"Active\", DateCalculationVersion: 1},\n\t\t{Name: \"V1 Sub 2\", Cost: 20, Schedule: \"Annual\", Status: \"Active\", DateCalculationVersion: 1},\n\t\t{Name: \"V2 Sub 1\", Cost: 15, Schedule: \"Monthly\", Status: \"Active\", DateCalculationVersion: 2},\n\t}\n\n\tfor _, sub := range subs {\n\t\terr := db.Create(&sub).Error\n\t\tassert.NoError(t, err)\n\t}\n\n\t// Create a migration log entry\n\tlog := &DateMigrationLog{\n\t\tSubscriptionID:     subs[0].ID,\n\t\tOldVersion:        1,\n\t\tNewVersion:        2,\n\t\tOldRenewalDate:    nil,\n\t\tNewRenewalDate:    nil,\n\t\tMigrationReason:   \"Test migration\",\n\t\tMigratedAt:        time.Now(),\n\t}\n\terr := db.Create(log).Error\n\tassert.NoError(t, err)\n\n\tstats, err := safety.GetMigrationStats()\n\tassert.NoError(t, err, \"Should get migration stats successfully\")\n\n\tassert.Equal(t, int64(2), stats[\"v1_subscriptions\"], \"Should have 2 V1 subscriptions\")\n\tassert.Equal(t, int64(1), stats[\"v2_subscriptions\"], \"Should have 1 V2 subscription\")\n\tassert.Equal(t, int64(1), stats[\"total_migrations\"], \"Should have 1 migration logged\")\n}"
  },
  {
    "path": "internal/models/exchange_rate.go",
    "content": "package models\n\nimport (\n\t\"time\"\n)\n\n// ExchangeRate represents currency exchange rate data\ntype ExchangeRate struct {\n\tID           uint      `json:\"id\" gorm:\"primaryKey\"`\n\tBaseCurrency string    `json:\"base_currency\" gorm:\"size:3;not null\"`\n\tCurrency     string    `json:\"currency\" gorm:\"size:3;not null\"`\n\tRate         float64   `json:\"rate\" gorm:\"not null\"`\n\tDate         time.Time `json:\"date\" gorm:\"not null\"`\n\tCreatedAt    time.Time `json:\"created_at\" gorm:\"autoCreateTime\"`\n\tUpdatedAt    time.Time `json:\"updated_at\" gorm:\"autoUpdateTime\"`\n}\n\n// IsStale checks if the exchange rate is older than 24 hours\nfunc (er *ExchangeRate) IsStale() bool {\n\treturn time.Since(er.Date) > 24*time.Hour\n}"
  },
  {
    "path": "internal/models/exchange_rate_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestExchangeRate_IsStale(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tlastUpdated    time.Time\n\t\texpectedStale  bool\n\t\tdescription    string\n\t}{\n\t\t{\n\t\t\tname:          \"Fresh rate - just updated\",\n\t\t\tlastUpdated:   time.Now(),\n\t\t\texpectedStale: false,\n\t\t\tdescription:   \"Rate updated now should not be stale\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Fresh rate - 30 minutes old\",\n\t\t\tlastUpdated:   time.Now().Add(-30 * time.Minute),\n\t\t\texpectedStale: false,\n\t\t\tdescription:   \"Rate updated 30 minutes ago should not be stale\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Stale rate - 25 hours old\",\n\t\t\tlastUpdated:   time.Now().Add(-25 * time.Hour),\n\t\t\texpectedStale: true,\n\t\t\tdescription:   \"Rate updated 25 hours ago should be stale\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Very stale rate - 2 days old\",\n\t\t\tlastUpdated:   time.Now().Add(-48 * time.Hour),\n\t\t\texpectedStale: true,\n\t\t\tdescription:   \"Rate updated 2 days ago should be stale\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Boundary case - just over 24 hours old\",\n\t\t\tlastUpdated:   time.Now().Add(-24*time.Hour - time.Minute),\n\t\t\texpectedStale: true,\n\t\t\tdescription:   \"Rate updated just over 24 hours ago should be stale\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Boundary case - just under 24 hours\",\n\t\t\tlastUpdated:   time.Now().Add(-23 * time.Hour),\n\t\t\texpectedStale: false,\n\t\t\tdescription:   \"Rate updated 23 hours ago should not be stale\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trate := &ExchangeRate{\n\t\t\t\tDate: tt.lastUpdated,\n\t\t\t}\n\n\t\t\tresult := rate.IsStale()\n\t\t\tassert.Equal(t, tt.expectedStale, result, tt.description)\n\t\t})\n\t}\n}"
  },
  {
    "path": "internal/models/settings.go",
    "content": "package models\n\nimport (\n\t\"time\"\n)\n\n// Settings represents application settings\ntype Settings struct {\n\tID        uint      `json:\"id\" gorm:\"primaryKey\"`\n\tKey       string    `json:\"key\" gorm:\"uniqueIndex;not null\"`\n\tValue     string    `json:\"value\"`\n\tCreatedAt time.Time `json:\"created_at\" gorm:\"autoCreateTime\"`\n\tUpdatedAt time.Time `json:\"updated_at\" gorm:\"autoUpdateTime\"`\n}\n\n// SMTPConfig represents SMTP configuration\ntype SMTPConfig struct {\n\tHost     string `json:\"smtp_host\"`\n\tPort     int    `json:\"smtp_port\"`\n\tUsername string `json:\"smtp_username\"`\n\tPassword string `json:\"smtp_password\"`\n\tFrom     string `json:\"smtp_from\"`\n\tFromName string `json:\"smtp_from_name\"`\n\tTo       string `json:\"smtp_to\"` // Recipient email address for notifications\n}\n\n// PushoverConfig represents Pushover notification configuration\ntype PushoverConfig struct {\n\tUserKey  string `json:\"pushover_user_key\"`  // Pushover user key\n\tAppToken string `json:\"pushover_app_token\"` // Pushover application token\n}\n\n// WebhookConfig represents generic webhook notification configuration\ntype WebhookConfig struct {\n\tURL     string            `json:\"webhook_url\"`\n\tHeaders map[string]string `json:\"webhook_headers\"`\n}\n\n// NotificationSettings represents notification preferences\ntype NotificationSettings struct {\n\tRenewalReminders         bool    `json:\"renewal_reminders\"`\n\tHighCostAlerts           bool    `json:\"high_cost_alerts\"`\n\tHighCostThreshold        float64 `json:\"high_cost_threshold\"`\n\tReminderDays             int     `json:\"reminder_days\"`\n\tCancellationReminders    bool    `json:\"cancellation_reminders\"`\n\tCancellationReminderDays int     `json:\"cancellation_reminder_days\"`\n}\n\n// APIKey represents an API key for external access\ntype APIKey struct {\n\tID         uint       `json:\"id\" gorm:\"primaryKey\"`\n\tName       string     `json:\"name\" gorm:\"not null\"`\n\tKey        string     `json:\"key\" gorm:\"uniqueIndex;not null\"`\n\tLastUsed   *time.Time `json:\"last_used\"`\n\tUsageCount int        `json:\"usage_count\" gorm:\"default:0\"`\n\tCreatedAt  time.Time  `json:\"created_at\" gorm:\"autoCreateTime\"`\n\tUpdatedAt  time.Time  `json:\"updated_at\" gorm:\"autoUpdateTime\"`\n\tIsNew      bool       `json:\"is_new\" gorm:\"-\"` // Not stored in DB, just for display\n}\n"
  },
  {
    "path": "internal/models/subscription.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/dromara/carbon/v2\"\n\t\"gorm.io/gorm\"\n)\n\ntype Subscription struct {\n\tID                           uint       `json:\"id\" gorm:\"primaryKey\"`\n\tName                         string     `json:\"name\" gorm:\"not null\" validate:\"required\"`\n\tCost                         float64    `json:\"cost\" gorm:\"not null\" validate:\"required,gt=0\"`\n\tOriginalCurrency             string     `json:\"original_currency\" gorm:\"size:3;default:'USD'\"`\n\tSchedule                     string     `json:\"schedule\" gorm:\"not null\" validate:\"required,oneof=Monthly Annual Weekly Daily Quarterly\"`\n\tStatus                       string     `json:\"status\" gorm:\"not null\" validate:\"required,oneof=Active Cancelled Paused Trial\"`\n\tCategoryID                   uint       `json:\"category_id\"`\n\tCategory                     Category   `json:\"category\" gorm:\"foreignKey:CategoryID\"`\n\tPaymentMethod                string     `json:\"payment_method\" gorm:\"\"`\n\tAccount                      string     `json:\"account\" gorm:\"\"`\n\tStartDate                    *time.Time `json:\"start_date\" gorm:\"\"`\n\tRenewalDate                  *time.Time `json:\"renewal_date\" gorm:\"\"`\n\tCancellationDate             *time.Time `json:\"cancellation_date\" gorm:\"\"`\n\tURL                          string     `json:\"url\" gorm:\"\"`\n\tIconURL                      string     `json:\"icon_url\" gorm:\"\"` // URL to subscription icon/logo\n\tNotes                        string     `json:\"notes\" gorm:\"\"`\n\tUsage                        string     `json:\"usage\" gorm:\"\" validate:\"omitempty,oneof=High Medium Low None\"`\n\tScheduleInterval             int        `json:\"schedule_interval\" gorm:\"default:1\"`\n\tReminderEnabled              bool       `json:\"reminder_enabled\" gorm:\"default:true\"`\n\tDateCalculationVersion       int        `json:\"date_calculation_version\" gorm:\"default:1\"`\n\tLastReminderSent             *time.Time `json:\"last_reminder_sent\" gorm:\"\"`              // Tracks when the last reminder was sent\n\tLastReminderRenewalDate      *time.Time `json:\"last_reminder_renewal_date\" gorm:\"\"`      // Tracks which renewal date the last reminder was for\n\tLastCancellationReminderSent *time.Time `json:\"last_cancellation_reminder_sent\" gorm:\"\"` // Tracks when the last cancellation reminder was sent\n\tLastCancellationReminderDate *time.Time `json:\"last_cancellation_reminder_date\" gorm:\"\"` // Tracks which cancellation date the last reminder was for\n\tCreatedAt                    time.Time  `json:\"created_at\" gorm:\"autoCreateTime\"`\n\tUpdatedAt                    time.Time  `json:\"updated_at\" gorm:\"autoUpdateTime\"`\n}\n\nfunc (s *Subscription) effectiveInterval() int {\n\tif s.ScheduleInterval <= 0 {\n\t\treturn 1\n\t}\n\treturn s.ScheduleInterval\n}\n\n// DisplaySchedule returns a human-friendly schedule label\nfunc (s *Subscription) DisplaySchedule() string {\n\tinterval := s.effectiveInterval()\n\tif interval == 1 {\n\t\treturn s.Schedule\n\t}\n\tunit := map[string]string{\n\t\t\"Daily\": \"Days\", \"Weekly\": \"Weeks\", \"Monthly\": \"Months\",\n\t\t\"Quarterly\": \"Quarters\", \"Annual\": \"Years\",\n\t}\n\tif u, ok := unit[s.Schedule]; ok {\n\t\treturn fmt.Sprintf(\"Every %d %s\", interval, u)\n\t}\n\treturn s.Schedule\n}\n\n// AnnualCost calculates the annual cost based on schedule\nfunc (s *Subscription) AnnualCost() float64 {\n\tinterval := s.effectiveInterval()\n\tswitch s.Schedule {\n\tcase \"Annual\":\n\t\treturn s.Cost / float64(interval)\n\tcase \"Quarterly\":\n\t\treturn s.Cost * 4 / float64(interval)\n\tcase \"Monthly\":\n\t\treturn s.Cost * 12 / float64(interval)\n\tcase \"Weekly\":\n\t\treturn s.Cost * 52 / float64(interval)\n\tcase \"Daily\":\n\t\treturn s.Cost * 365 / float64(interval)\n\tdefault:\n\t\treturn s.Cost * 12 / float64(interval)\n\t}\n}\n\n// MonthlyCost calculates the monthly cost based on schedule\nfunc (s *Subscription) MonthlyCost() float64 {\n\tinterval := s.effectiveInterval()\n\tswitch s.Schedule {\n\tcase \"Annual\":\n\t\treturn s.Cost / (12 * float64(interval))\n\tcase \"Quarterly\":\n\t\treturn s.Cost / (3 * float64(interval))\n\tcase \"Monthly\":\n\t\treturn s.Cost / float64(interval)\n\tcase \"Weekly\":\n\t\treturn s.Cost * 4.33 / float64(interval)\n\tcase \"Daily\":\n\t\treturn s.Cost * 30.44 / float64(interval)\n\tdefault:\n\t\treturn s.Cost / float64(interval)\n\t}\n}\n\n// DailyCost calculates the daily cost\nfunc (s *Subscription) DailyCost() float64 {\n\treturn s.MonthlyCost() / 30.44 // Average days per month\n}\n\n// IsHighCost determines if this is a high-cost subscription based on the threshold\nfunc (s *Subscription) IsHighCost(threshold float64) bool {\n\treturn s.MonthlyCost() > threshold\n}\n\n// BeforeCreate hook to set renewal date for active subscriptions\nfunc (s *Subscription) BeforeCreate(tx *gorm.DB) error {\n\tif s.Status == \"Active\" && s.RenewalDate == nil {\n\t\t// Set renewal date based on schedule\n\t\ts.calculateNextRenewalDate()\n\t}\n\treturn nil\n}\n\n// AfterFind hook to auto-update renewal date if it has passed (Issue #29)\n// This ensures renewal dates are automatically updated when subscriptions are loaded\nfunc (s *Subscription) AfterFind(tx *gorm.DB) error {\n\t// Auto-update renewal date if it has passed and subscription is active\n\tif s.RenewalDate != nil && s.Status == \"Active\" && s.ID > 0 {\n\t\tnow := time.Now()\n\t\tif s.RenewalDate.Before(now) || s.RenewalDate.Equal(now) {\n\t\t\t// Renewal date has passed, calculate the next one\n\t\t\toldRenewalDate := s.RenewalDate\n\t\t\ts.calculateNextRenewalDate()\n\n\t\t\t// Only update if the date actually changed to avoid unnecessary writes\n\t\t\tif s.RenewalDate != nil && !s.RenewalDate.Equal(*oldRenewalDate) {\n\t\t\t\t// Update only the renewal_date field using UpdateColumn to avoid triggering hooks\n\t\t\t\t// This prevents infinite recursion and only updates the specific field\n\t\t\t\ttx.Model(s).UpdateColumn(\"renewal_date\", s.RenewalDate)\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// BeforeUpdate hook to recalculate renewal date when schedule changes, start date changes, or date passes\nfunc (s *Subscription) BeforeUpdate(tx *gorm.DB) error {\n\t// Get the original values to check for schedule or start date changes\n\tvar original Subscription\n\tif err := tx.Model(&Subscription{}).Where(\"id = ?\", s.ID).First(&original).Error; err == nil {\n\t\t// If schedule changed and status is Active, recalculate renewal date\n\t\t// Use start date if available to preserve billing anniversary\n\t\tif (original.Schedule != s.Schedule || original.ScheduleInterval != s.ScheduleInterval) && s.Status == \"Active\" {\n\t\t\ts.calculateNextRenewalDate()\n\t\t}\n\n\t\t// If start date changed and status is Active, recalculate renewal date\n\t\t// This ensures renewal dates update when start dates are modified\n\t\tif s.Status == \"Active\" {\n\t\t\tstartDateChanged := false\n\t\t\tif original.StartDate == nil && s.StartDate != nil {\n\t\t\t\t// Start date was added\n\t\t\t\tstartDateChanged = true\n\t\t\t} else if original.StartDate != nil && s.StartDate == nil {\n\t\t\t\t// Start date was removed\n\t\t\t\tstartDateChanged = true\n\t\t\t} else if original.StartDate != nil && s.StartDate != nil {\n\t\t\t\t// Both exist, check if they're different\n\t\t\t\tif !original.StartDate.Equal(*s.StartDate) {\n\t\t\t\t\tstartDateChanged = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif startDateChanged {\n\t\t\t\ts.calculateNextRenewalDate()\n\t\t\t}\n\t\t}\n\t}\n\n\t// Calculate if renewal date is nil and status is Active\n\tif s.RenewalDate == nil && s.Status == \"Active\" {\n\t\ts.calculateNextRenewalDate()\n\t}\n\n\t// Auto-update renewal date if it has passed (Issue #29)\n\tif s.RenewalDate != nil && s.Status == \"Active\" {\n\t\tnow := time.Now()\n\t\tif s.RenewalDate.Before(now) || s.RenewalDate.Equal(now) {\n\t\t\t// Renewal date has passed, calculate the next one\n\t\t\ts.calculateNextRenewalDate()\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// calculateNextRenewalDate calculates the next renewal date based on schedule and version.\n//\n// Version Selection Logic:\n// - V1 (default): Original calculation logic for backward compatibility\n//   - All existing subscriptions use V1 unless explicitly migrated\n//   - Uses standard Go time.AddDate() which may cause edge cases\n//   - Example: Jan 31 + 1 month = Mar 3 (due to Feb having 28 days)\n//\n// - V2: Enhanced calculation using Carbon library for robust date arithmetic\n//   - Must be explicitly set via DateCalculationVersion = 2\n//   - Uses Carbon's AddMonthsNoOverflow/AddYearsNoOverflow for better handling\n//   - Example: Jan 31 + 1 month = Feb 28 (preserves month-end semantics)\n//   - Recommended for new subscriptions and can be migrated via migrate-dates command\nfunc (s *Subscription) calculateNextRenewalDate() {\n\t// Use versioned calculation approach\n\tswitch s.DateCalculationVersion {\n\tcase 2:\n\t\ts.calculateNextRenewalDateV2()\n\tdefault:\n\t\t// Use V1 logic for backward compatibility\n\t\ts.calculateNextRenewalDateV1()\n\t}\n}\n\n// calculateNextRenewalDateV1 uses the original calculation logic\nfunc (s *Subscription) calculateNextRenewalDateV1() {\n\t// If we have a start date, calculate renewal from start date\n\t// Otherwise, calculate from now\n\tif s.StartDate != nil {\n\t\ts.calculateNextRenewalDateFromStartDate()\n\t} else {\n\t\ts.calculateNextRenewalDateFromNow()\n\t}\n}\n\n// calculateNextRenewalDateV2 uses Carbon library for robust date handling\nfunc (s *Subscription) calculateNextRenewalDateV2() {\n\tif s.StartDate == nil {\n\t\ts.calculateNextRenewalDateFromNowV2()\n\t\treturn\n\t}\n\n\tinterval := s.effectiveInterval()\n\tstart := carbon.CreateFromStdTime(*s.StartDate)\n\tnow := carbon.Now()\n\n\tswitch s.Schedule {\n\tcase \"Monthly\":\n\t\tcurrent := start.Copy()\n\t\tfor current.Lte(now) {\n\t\t\tcurrent = current.AddMonthsNoOverflow(interval)\n\t\t}\n\t\trenewalDate := current.StdTime()\n\t\ts.RenewalDate = &renewalDate\n\n\tcase \"Quarterly\":\n\t\tcurrent := start.Copy()\n\t\tfor current.Lte(now) {\n\t\t\tcurrent = current.AddMonthsNoOverflow(3 * interval)\n\t\t}\n\t\trenewalDate := current.StdTime()\n\t\ts.RenewalDate = &renewalDate\n\n\tcase \"Annual\":\n\t\tcurrent := start.Copy()\n\t\tfor current.Lte(now) {\n\t\t\tcurrent = current.AddYearsNoOverflow(interval)\n\t\t}\n\t\trenewalDate := current.StdTime()\n\t\ts.RenewalDate = &renewalDate\n\n\tcase \"Weekly\":\n\t\tcurrent := start.Copy()\n\t\tfor current.Lte(now) {\n\t\t\tcurrent = current.AddWeeks(interval)\n\t\t}\n\t\trenewalDate := current.StdTime()\n\t\ts.RenewalDate = &renewalDate\n\n\tcase \"Daily\":\n\t\tcurrent := start.Copy()\n\t\tfor current.Lte(now) {\n\t\t\tcurrent = current.AddDays(interval)\n\t\t}\n\t\trenewalDate := current.StdTime()\n\t\ts.RenewalDate = &renewalDate\n\n\tdefault:\n\t\tcurrent := start.Copy()\n\t\tfor current.Lte(now) {\n\t\t\tcurrent = current.AddMonthsNoOverflow(interval)\n\t\t}\n\t\trenewalDate := current.StdTime()\n\t\ts.RenewalDate = &renewalDate\n\t}\n}\n\n// calculateNextRenewalDateFromStartDate calculates the next renewal date from start date\nfunc (s *Subscription) calculateNextRenewalDateFromStartDate() {\n\tif s.StartDate == nil {\n\t\ts.calculateNextRenewalDateFromNow()\n\t\treturn\n\t}\n\n\tinterval := s.effectiveInterval()\n\tvar renewalDate time.Time\n\tbaseDate := *s.StartDate\n\tnow := time.Now()\n\n\tswitch s.Schedule {\n\tcase \"Annual\":\n\t\tyears := interval\n\t\tfor {\n\t\t\trenewalDate = baseDate.AddDate(years, 0, 0)\n\t\t\tif renewalDate.After(now) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tyears += interval\n\t\t}\n\tcase \"Quarterly\":\n\t\tstartDay := baseDate.Day()\n\t\tstartYear := baseDate.Year()\n\t\tstartMonth := int(baseDate.Month())\n\t\tstep := 3 * interval\n\t\tperiods := 1\n\n\t\tfor {\n\t\t\ttotalMonths := startMonth + (periods * step) - 1\n\t\t\ttargetYear := startYear + totalMonths/12\n\t\t\ttargetMonth := time.Month((totalMonths % 12) + 1)\n\t\t\tlastDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, baseDate.Location()).Day()\n\t\t\ttargetDay := startDay\n\t\t\tif startDay > lastDay {\n\t\t\t\ttargetDay = lastDay\n\t\t\t}\n\t\t\trenewalDate = time.Date(targetYear, targetMonth, targetDay,\n\t\t\t\tbaseDate.Hour(), baseDate.Minute(), baseDate.Second(),\n\t\t\t\tbaseDate.Nanosecond(), baseDate.Location())\n\t\t\tif renewalDate.After(now) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tperiods++\n\t\t}\n\tcase \"Monthly\":\n\t\tstartDay := baseDate.Day()\n\t\tstartYear := baseDate.Year()\n\t\tstartMonth := int(baseDate.Month())\n\t\tperiods := 1\n\n\t\tfor {\n\t\t\ttotalMonths := startMonth + (periods * interval) - 1\n\t\t\ttargetYear := startYear + totalMonths/12\n\t\t\ttargetMonth := time.Month((totalMonths % 12) + 1)\n\t\t\tlastDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, baseDate.Location()).Day()\n\t\t\ttargetDay := startDay\n\t\t\tif startDay > lastDay {\n\t\t\t\ttargetDay = lastDay\n\t\t\t}\n\t\t\trenewalDate = time.Date(targetYear, targetMonth, targetDay,\n\t\t\t\tbaseDate.Hour(), baseDate.Minute(), baseDate.Second(),\n\t\t\t\tbaseDate.Nanosecond(), baseDate.Location())\n\t\t\tif renewalDate.After(now) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tperiods++\n\t\t}\n\tcase \"Weekly\":\n\t\tweeks := interval\n\t\tfor {\n\t\t\trenewalDate = baseDate.AddDate(0, 0, weeks*7)\n\t\t\tif renewalDate.After(now) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tweeks += interval\n\t\t}\n\tcase \"Daily\":\n\t\tdays := interval\n\t\tfor {\n\t\t\trenewalDate = baseDate.AddDate(0, 0, days)\n\t\t\tif renewalDate.After(now) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tdays += interval\n\t\t}\n\tdefault:\n\t\tstartDay := baseDate.Day()\n\t\tstartYear := baseDate.Year()\n\t\tstartMonth := int(baseDate.Month())\n\t\tperiods := 1\n\n\t\tfor {\n\t\t\ttotalMonths := startMonth + (periods * interval) - 1\n\t\t\ttargetYear := startYear + totalMonths/12\n\t\t\ttargetMonth := time.Month((totalMonths % 12) + 1)\n\t\t\tlastDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, baseDate.Location()).Day()\n\t\t\ttargetDay := startDay\n\t\t\tif startDay > lastDay {\n\t\t\t\ttargetDay = lastDay\n\t\t\t}\n\t\t\trenewalDate = time.Date(targetYear, targetMonth, targetDay,\n\t\t\t\tbaseDate.Hour(), baseDate.Minute(), baseDate.Second(),\n\t\t\t\tbaseDate.Nanosecond(), baseDate.Location())\n\t\t\tif renewalDate.After(now) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tperiods++\n\t\t}\n\t}\n\n\ts.RenewalDate = &renewalDate\n}\n\n// calculateNextRenewalDateFromNow calculates the next renewal date from current time\nfunc (s *Subscription) calculateNextRenewalDateFromNow() {\n\tinterval := s.effectiveInterval()\n\tvar renewalDate time.Time\n\tbaseDate := time.Now()\n\n\tswitch s.Schedule {\n\tcase \"Annual\":\n\t\trenewalDate = baseDate.AddDate(interval, 0, 0)\n\tcase \"Quarterly\":\n\t\trenewalDate = baseDate.AddDate(0, 3*interval, 0)\n\tcase \"Monthly\":\n\t\trenewalDate = baseDate.AddDate(0, interval, 0)\n\tcase \"Weekly\":\n\t\trenewalDate = baseDate.AddDate(0, 0, 7*interval)\n\tcase \"Daily\":\n\t\trenewalDate = baseDate.AddDate(0, 0, interval)\n\tdefault:\n\t\trenewalDate = baseDate.AddDate(0, interval, 0)\n\t}\n\ts.RenewalDate = &renewalDate\n}\n\n// calculateNextRenewalDateFromNowV2 calculates renewal date from now using Carbon\nfunc (s *Subscription) calculateNextRenewalDateFromNowV2() {\n\tinterval := s.effectiveInterval()\n\tnow := carbon.Now()\n\n\tswitch s.Schedule {\n\tcase \"Annual\":\n\t\trenewalDate := now.AddYearsNoOverflow(interval).StdTime()\n\t\ts.RenewalDate = &renewalDate\n\tcase \"Quarterly\":\n\t\trenewalDate := now.AddMonthsNoOverflow(3 * interval).StdTime()\n\t\ts.RenewalDate = &renewalDate\n\tcase \"Monthly\":\n\t\trenewalDate := now.AddMonthsNoOverflow(interval).StdTime()\n\t\ts.RenewalDate = &renewalDate\n\tcase \"Weekly\":\n\t\trenewalDate := now.AddWeeks(interval).StdTime()\n\t\ts.RenewalDate = &renewalDate\n\tcase \"Daily\":\n\t\trenewalDate := now.AddDays(interval).StdTime()\n\t\ts.RenewalDate = &renewalDate\n\tdefault:\n\t\trenewalDate := now.AddMonthsNoOverflow(interval).StdTime()\n\t\ts.RenewalDate = &renewalDate\n\t}\n}\n\n// Stats represents aggregated subscription statistics\ntype Stats struct {\n\tTotalMonthlySpend      float64            `json:\"total_monthly_spend\"`\n\tTotalAnnualSpend       float64            `json:\"total_annual_spend\"`\n\tActiveSubscriptions    int                `json:\"active_subscriptions\"`\n\tCancelledSubscriptions int                `json:\"cancelled_subscriptions\"`\n\tTotalSaved             float64            `json:\"total_saved\"`\n\tMonthlySaved           float64            `json:\"monthly_saved\"`\n\tUpcomingRenewals       int                `json:\"upcoming_renewals\"`\n\tCategorySpending       map[string]float64 `json:\"category_spending\"`\n}\n\n// CategoryStat represents spending by category\ntype CategoryStat struct {\n\tCategory string  `json:\"category\"`\n\tAmount   float64 `json:\"amount\"`\n\tCount    int     `json:\"count\"`\n}\n"
  },
  {
    "path": "internal/models/subscription_test.go",
    "content": "package models\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc setupTestDB(t *testing.T) *gorm.DB {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\n\t// Migrate the schema\n\terr = db.AutoMigrate(&Subscription{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\treturn db\n}\n\nfunc TestSubscription_CalculateNextRenewalDate(t *testing.T) {\n\tnow := time.Now()\n\ttests := []struct {\n\t\tname             string\n\t\tschedule         string\n\t\tstartDate        *time.Time\n\t\texpectedDuration time.Duration\n\t\tdescription      string\n\t}{\n\t\t{\n\t\t\tname:             \"Monthly schedule\",\n\t\t\tschedule:         \"Monthly\",\n\t\t\tstartDate:        &now,\n\t\t\texpectedDuration: 30 * 24 * time.Hour, // Approximately 30 days\n\t\t\tdescription:      \"Should add approximately 1 month\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Annual schedule\",\n\t\t\tschedule:         \"Annual\",\n\t\t\tstartDate:        &now,\n\t\t\texpectedDuration: 365 * 24 * time.Hour, // Approximately 365 days\n\t\t\tdescription:      \"Should add approximately 1 year\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Weekly schedule\",\n\t\t\tschedule:         \"Weekly\",\n\t\t\tstartDate:        &now,\n\t\t\texpectedDuration: 7 * 24 * time.Hour, // Exactly 7 days\n\t\t\tdescription:      \"Should add exactly 7 days\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Daily schedule\",\n\t\t\tschedule:         \"Daily\",\n\t\t\tstartDate:        &now,\n\t\t\texpectedDuration: 24 * time.Hour, // Exactly 1 day\n\t\t\tdescription:      \"Should add exactly 1 day\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule:  tt.schedule,\n\t\t\t\tStartDate: tt.startDate,\n\t\t\t\tStatus:    \"Active\",\n\t\t\t}\n\n\t\t\tsub.calculateNextRenewalDate()\n\n\t\t\tassert.NotNil(t, sub.RenewalDate, tt.description)\n\n\t\t\tif tt.schedule == \"Monthly\" {\n\t\t\t\t// For monthly, check it's in the next month\n\t\t\t\texpectedMonth := now.AddDate(0, 1, 0)\n\t\t\t\tassert.Equal(t, expectedMonth.Month(), sub.RenewalDate.Month())\n\t\t\t\tassert.Equal(t, expectedMonth.Year(), sub.RenewalDate.Year())\n\t\t\t} else if tt.schedule == \"Annual\" {\n\t\t\t\t// For annual, check it's in the next year\n\t\t\t\texpectedYear := now.AddDate(1, 0, 0)\n\t\t\t\tassert.Equal(t, expectedYear.Year(), sub.RenewalDate.Year())\n\t\t\t} else {\n\t\t\t\t// For weekly and daily, we can check exact duration\n\t\t\t\tactualDuration := sub.RenewalDate.Sub(*tt.startDate)\n\t\t\t\tassert.InDelta(t, tt.expectedDuration.Hours(), actualDuration.Hours(), 1, tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSubscription_CalculateNextRenewalDateFromNow(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tschedule string\n\t\tstatus   string\n\t}{\n\t\t{\n\t\t\tname:     \"Monthly renewal from now\",\n\t\t\tschedule: \"Monthly\",\n\t\t\tstatus:   \"Active\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Annual renewal from now\",\n\t\t\tschedule: \"Annual\",\n\t\t\tstatus:   \"Active\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Weekly renewal from now\",\n\t\t\tschedule: \"Weekly\",\n\t\t\tstatus:   \"Active\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule: tt.schedule,\n\t\t\t\tStatus:   tt.status,\n\t\t\t}\n\n\t\t\tsub.calculateNextRenewalDateFromNow()\n\n\t\t\tassert.NotNil(t, sub.RenewalDate)\n\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()), \"Renewal date should be in the future\")\n\t\t})\n\t}\n}\n\nfunc TestSubscription_BeforeUpdate_ScheduleChange(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\t// Create a subscription with initial schedule\n\tstartDate := time.Now().AddDate(0, -3, 0) // 3 months ago\n\trenewalDate := time.Now().AddDate(0, 1, 0) // 1 month from now\n\tsub := &Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        9.99,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tStartDate:   &startDate,\n\t\tRenewalDate: &renewalDate,\n\t}\n\n\t// Save the subscription\n\terr := db.Create(sub).Error\n\tassert.NoError(t, err)\n\n\t// Simulate schedule change by fetching and updating\n\tvar existing Subscription\n\terr = db.First(&existing, sub.ID).Error\n\tassert.NoError(t, err)\n\n\t// Change schedule from Monthly to Annual\n\texisting.Schedule = \"Annual\"\n\n\t// Trigger BeforeUpdate hook\n\terr = existing.BeforeUpdate(db)\n\tassert.NoError(t, err)\n\n\t// Verify renewal date was recalculated\n\tassert.NotNil(t, existing.RenewalDate)\n\t// The new renewal date should be in the future (using start date + schedule)\n\tassert.True(t, existing.RenewalDate.After(time.Now()), \"Renewal should be in future\")\n\t// For schedule change from Monthly to Annual, it should preserve the start date anniversary\n\tassert.Equal(t, startDate.Month(), existing.RenewalDate.Month(), \"Should preserve start date month\")\n\tassert.Equal(t, startDate.Day(), existing.RenewalDate.Day(), \"Should preserve start date day\")\n}\n\nfunc TestSubscription_BeforeUpdate_NoScheduleChange(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\t// Create a subscription\n\toriginalRenewal := time.Now().AddDate(0, 1, 0)\n\tsub := &Subscription{\n\t\tID:          1,\n\t\tName:        \"Test Subscription\",\n\t\tCost:        9.99,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: &originalRenewal,\n\t}\n\n\t// Save the subscription\n\terr := db.Create(sub).Error\n\tassert.NoError(t, err)\n\n\t// Update without changing schedule\n\tsub.Cost = 19.99\n\n\t// Trigger BeforeUpdate hook\n\terr = sub.BeforeUpdate(db)\n\tassert.NoError(t, err)\n\n\t// Verify renewal date was NOT changed\n\tassert.NotNil(t, sub.RenewalDate)\n\tassert.Equal(t, originalRenewal.Format(\"2006-01-02\"), sub.RenewalDate.Format(\"2006-01-02\"))\n}\n\nfunc TestSubscription_BeforeUpdate_NilRenewalDate(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\t// Create a subscription without renewal date\n\tsub := &Subscription{\n\t\tID:          1,\n\t\tName:        \"Test Subscription\",\n\t\tCost:        9.99,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: nil, // No renewal date set\n\t}\n\n\t// Save the subscription\n\terr := db.Create(sub).Error\n\tassert.NoError(t, err)\n\n\t// Trigger BeforeUpdate hook\n\terr = sub.BeforeUpdate(db)\n\tassert.NoError(t, err)\n\n\t// Verify renewal date was calculated\n\tassert.NotNil(t, sub.RenewalDate)\n\tassert.True(t, sub.RenewalDate.After(time.Now()))\n}\n\nfunc TestSubscription_MonthlyCost(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tschedule string\n\t\tcost     float64\n\t\texpected float64\n\t}{\n\t\t{\n\t\t\tname:     \"Monthly subscription\",\n\t\t\tschedule: \"Monthly\",\n\t\t\tcost:     10.00,\n\t\t\texpected: 10.00,\n\t\t},\n\t\t{\n\t\t\tname:     \"Annual subscription\",\n\t\t\tschedule: \"Annual\",\n\t\t\tcost:     120.00,\n\t\t\texpected: 10.00,\n\t\t},\n\t\t{\n\t\t\tname:     \"Weekly subscription\",\n\t\t\tschedule: \"Weekly\",\n\t\t\tcost:     10.00,\n\t\t\texpected: 43.30, // 10 * 52 / 12 = 43.333...\n\t\t},\n\t\t{\n\t\t\tname:     \"Daily subscription\",\n\t\t\tschedule: \"Daily\",\n\t\t\tcost:     1.00,\n\t\t\texpected: 30.44,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule: tt.schedule,\n\t\t\t\tCost:     tt.cost,\n\t\t\t}\n\n\t\t\tresult := sub.MonthlyCost()\n\t\t\tassert.InDelta(t, tt.expected, result, 0.01)\n\t\t})\n\t}\n}\n\nfunc TestSubscription_BeforeCreate_WithStartDate(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\ttests := []struct {\n\t\tname         string\n\t\tschedule     string\n\t\tstartDate    time.Time\n\t\tdescription  string\n\t}{\n\t\t{\n\t\t\tname:         \"Monthly subscription with past start date\",\n\t\t\tschedule:     \"Monthly\",\n\t\t\tstartDate:    time.Now().AddDate(0, -2, -15), // 2.5 months ago\n\t\t\tdescription:  \"Should calculate next monthly anniversary\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Annual subscription with past start date\",\n\t\t\tschedule:     \"Annual\",\n\t\t\tstartDate:    time.Now().AddDate(0, -6, 0), // 6 months ago\n\t\t\tdescription:  \"Should calculate next annual anniversary\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Weekly subscription with past start date\",\n\t\t\tschedule:     \"Weekly\",\n\t\t\tstartDate:    time.Now().AddDate(0, 0, -10), // 10 days ago\n\t\t\tdescription:  \"Should calculate next weekly anniversary\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Future start date\",\n\t\t\tschedule:     \"Monthly\",\n\t\t\tstartDate:    time.Now().AddDate(0, 0, 7), // 7 days in future\n\t\t\tdescription:  \"Should set renewal one month after future start date\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{\n\t\t\t\tName:      \"Test Subscription\",\n\t\t\t\tCost:      9.99,\n\t\t\t\tSchedule:  tt.schedule,\n\t\t\t\tStatus:    \"Active\",\n\t\t\t\tStartDate: &tt.startDate,\n\t\t\t}\n\n\t\t\t// Trigger BeforeCreate hook\n\t\t\terr := sub.BeforeCreate(db)\n\t\t\tassert.NoError(t, err)\n\n\t\t\t// Verify renewal date was set\n\t\t\tassert.NotNil(t, sub.RenewalDate, tt.description)\n\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()), \"Renewal date should be in the future\")\n\n\t\t\t// For past start dates, verify it's the next occurrence\n\t\t\tif tt.startDate.Before(time.Now()) {\n\t\t\t\t// The renewal should be after now but follow the schedule pattern\n\t\t\t\tswitch tt.schedule {\n\t\t\t\tcase \"Monthly\":\n\t\t\t\t\t// Should be on the same day of month as start date, unless start date is month-end\n\t\t\t\t\tstartYear, startMonth, _ := tt.startDate.Date()\n\t\t\t\t\trenewalYear, renewalMonth, _ := sub.RenewalDate.Date()\n\t\t\t\t\tstartLastDay := time.Date(startYear, startMonth+1, 0, 0, 0, 0, 0, tt.startDate.Location()).Day()\n\t\t\t\t\trenewalLastDay := time.Date(renewalYear, renewalMonth+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day()\n\t\t\t\t\tif tt.startDate.Day() == startLastDay {\n\t\t\t\t\t\tassert.Equal(t, renewalLastDay, sub.RenewalDate.Day(), \"Renewal date should be last day of month if start date was\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\tassert.Equal(t, tt.startDate.Day(), sub.RenewalDate.Day())\n\t\t\t\t\t}\n\t\t\t\tcase \"Annual\":\n\t\t\t\t\t// Should be on same month/day as start date\n\t\t\t\t\tassert.Equal(t, tt.startDate.Month(), sub.RenewalDate.Month())\n\t\t\t\t\tassert.Equal(t, tt.startDate.Day(), sub.RenewalDate.Day())\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSubscription_AnnualCost(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tschedule string\n\t\tcost     float64\n\t\texpected float64\n\t}{\n\t\t{\n\t\t\tname:     \"Monthly subscription\",\n\t\t\tschedule: \"Monthly\",\n\t\t\tcost:     10.00,\n\t\t\texpected: 120.00,\n\t\t},\n\t\t{\n\t\t\tname:     \"Annual subscription\",\n\t\t\tschedule: \"Annual\",\n\t\t\tcost:     120.00,\n\t\t\texpected: 120.00,\n\t\t},\n\t\t{\n\t\t\tname:     \"Weekly subscription\",\n\t\t\tschedule: \"Weekly\",\n\t\t\tcost:     10.00,\n\t\t\texpected: 520.00,\n\t\t},\n\t\t{\n\t\t\tname:     \"Daily subscription\",\n\t\t\tschedule: \"Daily\",\n\t\t\tcost:     1.00,\n\t\t\texpected: 365.00,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule: tt.schedule,\n\t\t\t\tCost:     tt.cost,\n\t\t\t}\n\n\t\t\tresult := sub.AnnualCost()\n\t\t\tassert.InDelta(t, tt.expected, result, 0.01)\n\t\t})\n\t}\n}\n\n// TestSubscription_DailyCost tests daily cost calculation\nfunc TestSubscription_DailyCost(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tschedule string\n\t\tcost     float64\n\t\texpected float64\n\t}{\n\t\t{\n\t\t\tname:     \"Monthly subscription\",\n\t\t\tschedule: \"Monthly\",\n\t\t\tcost:     30.44, // Should result in 1.00 daily\n\t\t\texpected: 1.00,\n\t\t},\n\t\t{\n\t\t\tname:     \"Annual subscription\",\n\t\t\tschedule: \"Annual\",\n\t\t\tcost:     365.00, // Should result in ~1.00 daily\n\t\t\texpected: 1.00,\n\t\t},\n\t\t{\n\t\t\tname:     \"Weekly subscription\",\n\t\t\tschedule: \"Weekly\",\n\t\t\tcost:     7.00, // Should result in ~1.00 daily\n\t\t\texpected: 1.00,\n\t\t},\n\t\t{\n\t\t\tname:     \"Daily subscription\",\n\t\t\tschedule: \"Daily\",\n\t\t\tcost:     2.00,\n\t\t\texpected: 2.00,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule: tt.schedule,\n\t\t\t\tCost:     tt.cost,\n\t\t\t}\n\n\t\t\tresult := sub.DailyCost()\n\t\t\tassert.InDelta(t, tt.expected, result, 0.01)\n\t\t})\n\t}\n}\n\n// TestSubscription_IsHighCost tests high cost detection\nfunc TestSubscription_IsHighCost(t *testing.T) {\n\tthreshold := 50.0 // Default threshold\n\ttests := []struct {\n\t\tname     string\n\t\tschedule string\n\t\tcost     float64\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"Low cost monthly\",\n\t\t\tschedule: \"Monthly\",\n\t\t\tcost:     25.00,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"High cost monthly\",\n\t\t\tschedule: \"Monthly\",\n\t\t\tcost:     75.00,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Boundary case - exactly 50\",\n\t\t\tschedule: \"Monthly\",\n\t\t\tcost:     50.00,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Boundary case - just over 50\",\n\t\t\tschedule: \"Monthly\",\n\t\t\tcost:     50.01,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"High cost annual (converted to monthly)\",\n\t\t\tschedule: \"Annual\",\n\t\t\tcost:     720.00, // $60/month\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Low cost weekly (converted to monthly)\",\n\t\t\tschedule: \"Weekly\",\n\t\t\tcost:     10.00, // ~$43.30/month\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule: tt.schedule,\n\t\t\t\tCost:     tt.cost,\n\t\t\t}\n\n\t\t\tresult := sub.IsHighCost(threshold)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\n// TestSubscription_DateEdgeCases tests critical edge cases for date calculations\n// Note: These tests focus on the core logic, not exact historical sequences\nfunc TestSubscription_DateEdgeCases(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tstartDate     string\n\t\tschedule      string\n\t\texpectedBehavior string\n\t\tdescription   string\n\t}{\n\t\t{\n\t\t\tname:          \"January 31st Monthly - Month End Handling\",\n\t\t\tstartDate:     \"2025-01-31T10:00:00Z\",\n\t\t\tschedule:      \"Monthly\",\n\t\t\texpectedBehavior: \"future_month_end\",\n\t\t\tdescription:   \"Jan 31 should calculate next month-end after current date\",\n\t\t},\n\t\t{\n\t\t\tname:          \"February 29th Leap Year - Next Occurrence\",\n\t\t\tstartDate:     \"2024-02-29T10:00:00Z\", // 2024 is leap year\n\t\t\tschedule:      \"Monthly\",\n\t\t\texpectedBehavior: \"next_valid_date\",\n\t\t\tdescription:   \"Feb 29 (leap) should find next valid renewal after current date\",\n\t\t},\n\t\t{\n\t\t\tname:          \"February 29th Annual - Leap Year Handling\",\n\t\t\tstartDate:     \"2024-02-29T10:00:00Z\",\n\t\t\tschedule:      \"Annual\",\n\t\t\texpectedBehavior: \"next_anniversary\",\n\t\t\tdescription:   \"Feb 29 annual should find next anniversary after current date\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Past Start Date Monthly\",\n\t\t\tstartDate:     \"2024-01-31T10:00:00Z\", // Past date\n\t\t\tschedule:      \"Monthly\",\n\t\t\texpectedBehavior: \"next_occurrence_after_now\",\n\t\t\tdescription:   \"Past start date should find next occurrence after current time\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Future Start Date Monthly\",\n\t\t\tstartDate:     \"2025-10-15T10:00:00Z\", // Future date\n\t\t\tschedule:      \"Monthly\",\n\t\t\texpectedBehavior: \"first_renewal_after_start\",\n\t\t\tdescription:   \"Future start date should calculate first renewal properly\",\n\t\t},\n\t\t{\n\t\t\tname:          \"July 31st Monthly - Current Edge Case\",\n\t\t\tstartDate:     \"2025-07-31T10:00:00Z\",\n\t\t\tschedule:      \"Monthly\",\n\t\t\texpectedBehavior: \"next_month_end\",\n\t\t\tdescription:   \"July 31 should handle month-end logic correctly\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstartTime, err := time.Parse(time.RFC3339, tt.startDate)\n\t\t\tassert.NoError(t, err, \"Failed to parse start date\")\n\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule:  tt.schedule,\n\t\t\t\tStartDate: &startTime,\n\t\t\t\tStatus:    \"Active\",\n\t\t\t}\n\n\t\t\t// Test renewal calculation\n\t\t\tsub.calculateNextRenewalDate()\n\t\t\tassert.NotNil(t, sub.RenewalDate, tt.description)\n\n\t\t\t// All renewal dates should be in the future\n\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()),\n\t\t\t\t\"Renewal date should be in the future for %s\", tt.description)\n\n\t\t\t// Test specific behaviors based on the expected behavior\n\t\t\tswitch tt.expectedBehavior {\n\t\t\tcase \"future_month_end\":\n\t\t\t\t// Should preserve month-end logic\n\t\t\t\tlastDayOfRenewalMonth := time.Date(sub.RenewalDate.Year(),\n\t\t\t\t\tsub.RenewalDate.Month()+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day()\n\t\t\t\tassert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= lastDayOfRenewalMonth,\n\t\t\t\t\t\"Should preserve month-end logic for %s\", tt.description)\n\n\t\t\tcase \"next_occurrence_after_now\":\n\t\t\t\t// Should find next occurrence after now\n\t\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()),\n\t\t\t\t\t\"Should be after current time for %s\", tt.description)\n\t\t\t\t// For Jan 31 start, should preserve month-end logic\n\t\t\t\tif startTime.Day() == 31 {\n\t\t\t\t\tlastDay := time.Date(sub.RenewalDate.Year(),\n\t\t\t\t\t\tsub.RenewalDate.Month()+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day()\n\t\t\t\t\tassert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= lastDay,\n\t\t\t\t\t\t\"Should preserve month-end for past Jan 31\")\n\t\t\t\t}\n\n\t\t\tcase \"first_renewal_after_start\":\n\t\t\t\t// For future dates, should be exactly one period after start\n\t\t\t\tif tt.schedule == \"Monthly\" {\n\t\t\t\t\texpected := startTime.AddDate(0, 1, 0)\n\t\t\t\t\tassert.Equal(t, expected.Day(), sub.RenewalDate.Day(),\n\t\t\t\t\t\t\"Should be one month after start for %s\", tt.description)\n\t\t\t\t}\n\n\t\t\tcase \"next_month_end\":\n\t\t\t\t// July 31 -> should find next month-end occurrence after current date\n\t\t\t\tlastDay := time.Date(sub.RenewalDate.Year(),\n\t\t\t\t\tsub.RenewalDate.Month()+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day()\n\t\t\t\tassert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= lastDay,\n\t\t\t\t\t\"Should handle month-end correctly for %s\", tt.description)\n\n\t\t\tdefault:\n\t\t\t\t// Just verify it's a valid future date\n\t\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()),\n\t\t\t\t\t\"Should be a valid future date for %s\", tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestSubscription_ScheduleChangePreservation tests that schedule changes preserve billing anniversary\nfunc TestSubscription_ScheduleChangePreservation(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\ttests := []struct {\n\t\tname           string\n\t\tinitialSchedule string\n\t\tnewSchedule     string\n\t\tstartDate       string\n\t\texpectedDay     int\n\t\tdescription     string\n\t}{\n\t\t{\n\t\t\tname:            \"Monthly to Annual preserves day\",\n\t\t\tinitialSchedule: \"Monthly\",\n\t\t\tnewSchedule:     \"Annual\",\n\t\t\tstartDate:       \"2025-01-15T10:00:00Z\",\n\t\t\texpectedDay:     15,\n\t\t\tdescription:     \"Changing Monthly → Annual should preserve 15th\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Annual to Monthly preserves day\",\n\t\t\tinitialSchedule: \"Annual\",\n\t\t\tnewSchedule:     \"Monthly\",\n\t\t\tstartDate:       \"2024-03-20T10:00:00Z\",\n\t\t\texpectedDay:     20,\n\t\t\tdescription:     \"Changing Annual → Monthly should preserve 20th\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Monthly to Annual with month-end date\",\n\t\t\tinitialSchedule: \"Monthly\",\n\t\t\tnewSchedule:     \"Annual\",\n\t\t\tstartDate:       \"2024-01-31T10:00:00Z\",\n\t\t\texpectedDay:     31,\n\t\t\tdescription:     \"Jan 31 Monthly → Annual should preserve 31st\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Weekly to Monthly preserves weekday as much as possible\",\n\t\t\tinitialSchedule: \"Weekly\",\n\t\t\tnewSchedule:     \"Monthly\",\n\t\t\tstartDate:       \"2025-01-07T10:00:00Z\", // Tuesday\n\t\t\texpectedDay:     7,\n\t\t\tdescription:     \"Weekly → Monthly should preserve original date\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstartTime, err := time.Parse(time.RFC3339, tt.startDate)\n\t\t\tassert.NoError(t, err)\n\n\t\t\t// Create subscription with initial schedule\n\t\t\tsub := &Subscription{\n\t\t\t\tName:      \"Test Subscription\",\n\t\t\t\tCost:      9.99,\n\t\t\t\tSchedule:  tt.initialSchedule,\n\t\t\t\tStatus:    \"Active\",\n\t\t\t\tStartDate: &startTime,\n\t\t\t}\n\n\t\t\terr = db.Create(sub).Error\n\t\t\tassert.NoError(t, err)\n\n\t\t\t// Load the subscription to get the initial renewal date\n\t\t\tvar loaded Subscription\n\t\t\terr = db.First(&loaded, sub.ID).Error\n\t\t\tassert.NoError(t, err)\n\n\t\t\t// Change the schedule\n\t\t\tloaded.Schedule = tt.newSchedule\n\n\t\t\t// Trigger the BeforeUpdate hook\n\t\t\terr = loaded.BeforeUpdate(db)\n\t\t\tassert.NoError(t, err)\n\n\t\t\t// Verify the renewal date preserves the billing anniversary\n\t\t\tassert.NotNil(t, loaded.RenewalDate, tt.description)\n\t\t\tif tt.name != \"Weekly to Monthly preserves weekday as much as possible\" {\n\t\t\t\tassert.Equal(t, tt.expectedDay, loaded.RenewalDate.Day(), tt.description)\n\t\t\t}\n\n\t\t\t// Ensure renewal is in the future\n\t\t\tassert.True(t, loaded.RenewalDate.After(time.Now()),\n\t\t\t\t\"Renewal should be in future for %s\", tt.description)\n\t\t})\n\t}\n}\n\n// TestSubscription_LeapYearHandling tests comprehensive leap year scenarios\nfunc TestSubscription_LeapYearHandling(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tstartDate     string\n\t\tschedule      string\n\t\ttestYears     []int\n\t\texpectedDays  []int\n\t\tdescription   string\n\t}{\n\t\t{\n\t\t\tname:        \"Feb 29 Monthly - Leap Year Handling\",\n\t\t\tstartDate:   \"2024-02-29T10:00:00Z\", // Leap year\n\t\t\tschedule:    \"Monthly\",\n\t\t\tdescription: \"Feb 29 should find next valid monthly renewal after current date\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Feb 29 Annual across multiple leap years\",\n\t\t\tstartDate:    \"2024-02-29T10:00:00Z\",\n\t\t\tschedule:     \"Annual\",\n\t\t\ttestYears:    []int{2025, 2026, 2027, 2028, 2029},\n\t\t\texpectedDays: []int{28, 28, 28, 29, 28}, // Non-leap years use 28th\n\t\t\tdescription:  \"Feb 29 Annual should use Feb 28 except in leap years\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstartTime, err := time.Parse(time.RFC3339, tt.startDate)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule:  tt.schedule,\n\t\t\t\tStartDate: &startTime,\n\t\t\t\tStatus:    \"Active\",\n\t\t\t}\n\n\t\t\t// Calculate the next renewal from the start date\n\t\t\tsub.calculateNextRenewalDate()\n\t\t\tassert.NotNil(t, sub.RenewalDate, tt.description)\n\n\t\t\t// Verify the renewal is in the future\n\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()),\n\t\t\t\t\"Leap year renewal should be in future for %s\", tt.description)\n\n\t\t\t// For leap year handling, verify it's reasonable\n\t\t\tif tt.name == \"Feb 29 Annual across multiple leap years\" {\n\t\t\t\tassert.True(t, sub.RenewalDate.Month() == time.February || sub.RenewalDate.Month() == time.March,\n\t\t\t\t\t\"Annual Feb 29 should result in Feb/Mar renewal\")\n\t\t\t\t// Be flexible with day range - could be Feb 28, Feb 29, or Mar 1\n\t\t\t\tassert.True(t, (sub.RenewalDate.Month() == time.February && sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= 29) ||\n\t\t\t\t\t(sub.RenewalDate.Month() == time.March && sub.RenewalDate.Day() == 1),\n\t\t\t\t\t\"Day should be Feb 28/29 or Mar 1 for leap year handling, got %v\", sub.RenewalDate)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestSubscription_TimezoneConsistency tests date calculations across timezones\nfunc TestSubscription_TimezoneConsistency(t *testing.T) {\n\ttimezones := []string{\n\t\t\"UTC\",\n\t\t\"America/New_York\",\n\t\t\"America/Los_Angeles\",\n\t\t\"Europe/London\",\n\t\t\"Asia/Tokyo\",\n\t\t\"Australia/Sydney\",\n\t}\n\n\tfor _, tz := range timezones {\n\t\tt.Run(\"Timezone \"+tz, func(t *testing.T) {\n\t\t\tlocation, err := time.LoadLocation(tz)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tstartTime := time.Date(2025, 1, 31, 12, 0, 0, 0, location)\n\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule:  \"Monthly\",\n\t\t\t\tStartDate: &startTime,\n\t\t\t\tStatus:    \"Active\",\n\t\t\t}\n\n\t\t\tsub.calculateNextRenewalDate()\n\n\t\t\tassert.NotNil(t, sub.RenewalDate)\n\t\t\t// Renewal should preserve the timezone\n\t\t\tassert.Equal(t, location, sub.RenewalDate.Location())\n\t\t\t// Should handle month-end correctly regardless of timezone\n\t\t\tassert.True(t, sub.RenewalDate.After(startTime))\n\t\t})\n\t}\n}\n\n// TestSubscription_DateCalculationV2 tests the Carbon-based V2 date calculation\nfunc TestSubscription_DateCalculationV2(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tstartDate     string\n\t\tschedule      string\n\t\texpectedNext  []string // First few renewal dates\n\t\tdescription   string\n\t}{\n\t\t{\n\t\t\tname:         \"V2 January 31st Monthly - Month End Handling\",\n\t\t\tstartDate:    \"2025-01-31T10:00:00Z\",\n\t\t\tschedule:     \"Monthly\",\n\t\t\texpectedNext: []string{\"2025-02-28\", \"2025-03-31\", \"2025-04-30\", \"2025-05-31\"},\n\t\t\tdescription:  \"Jan 31 → Feb 28 → Mar 31 → Apr 30 → May 31 (Carbon NoOverflow)\",\n\t\t},\n\t\t{\n\t\t\tname:         \"V2 February 29th Leap Year Monthly\",\n\t\t\tstartDate:    \"2024-02-29T10:00:00Z\",\n\t\t\tschedule:     \"Monthly\",\n\t\t\texpectedNext: []string{\"2024-03-29\", \"2024-04-29\", \"2024-05-29\"},\n\t\t\tdescription:  \"Feb 29 (leap) → Mar 29 → Apr 29 → May 29 (Carbon NoOverflow)\",\n\t\t},\n\t\t{\n\t\t\tname:         \"V2 March 31st Monthly - April Has 30 Days\",\n\t\t\tstartDate:    \"2025-03-31T10:00:00Z\",\n\t\t\tschedule:     \"Monthly\",\n\t\t\texpectedNext: []string{\"2025-04-30\", \"2025-05-31\", \"2025-06-30\", \"2025-07-31\"},\n\t\t\tdescription:  \"Mar 31 → Apr 30 → May 31 → Jun 30 → Jul 31 (Carbon NoOverflow)\",\n\t\t},\n\t\t{\n\t\t\tname:         \"V2 July 31st Monthly - August and September\",\n\t\t\tstartDate:    \"2025-07-31T10:00:00Z\",\n\t\t\tschedule:     \"Monthly\",\n\t\t\texpectedNext: []string{\"2025-08-31\", \"2025-09-30\", \"2025-10-31\", \"2025-11-30\"},\n\t\t\tdescription:  \"Jul 31 → Aug 31 → Sep 30 → Oct 31 → Nov 30 (Carbon NoOverflow)\",\n\t\t},\n\t\t{\n\t\t\tname:         \"V2 February 29th Annual Leap Year\",\n\t\t\tstartDate:    \"2024-02-29T10:00:00Z\",\n\t\t\tschedule:     \"Annual\",\n\t\t\texpectedNext: []string{\"2025-02-28\", \"2026-02-28\", \"2027-02-28\", \"2028-02-29\"},\n\t\t\tdescription:  \"Feb 29 leap → Feb 28 non-leap years → Feb 29 next leap (Carbon NoOverflow)\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstartTime, err := time.Parse(time.RFC3339, tt.startDate)\n\t\t\tassert.NoError(t, err, \"Failed to parse start date\")\n\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule:               tt.schedule,\n\t\t\t\tStartDate:              &startTime,\n\t\t\t\tStatus:                 \"Active\",\n\t\t\t\tDateCalculationVersion: 2, // Use V2 Carbon-based calculation\n\t\t\t}\n\n\t\t\t// Test V2 renewal calculation\n\t\t\tsub.calculateNextRenewalDate()\n\t\t\tassert.NotNil(t, sub.RenewalDate, tt.description)\n\n\t\t\t// All V2 calculations should result in future dates\n\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()),\n\t\t\t\t\"V2 renewal date should be in the future for %s\", tt.description)\n\n\t\t\t// Test V2 Carbon-based behaviors\n\t\t\tif strings.Contains(tt.name, \"January 31st\") || strings.Contains(tt.name, \"July 31st\") {\n\t\t\t\t// Should preserve month-end logic with Carbon's NoOverflow\n\t\t\t\tlastDay := time.Date(sub.RenewalDate.Year(),\n\t\t\t\t\tsub.RenewalDate.Month()+1, 0, 0, 0, 0, 0, sub.RenewalDate.Location()).Day()\n\t\t\t\tassert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= lastDay,\n\t\t\t\t\t\"Carbon should handle month-end correctly for %s\", tt.description)\n\t\t\t} else if strings.Contains(tt.name, \"February 29th\") {\n\t\t\t\t// Feb 29 should be handled gracefully by Carbon\n\t\t\t\tif tt.schedule == \"Annual\" {\n\t\t\t\t\t// Feb 29 annual should find next valid anniversary\n\t\t\t\t\tassert.True(t, sub.RenewalDate.Month() == time.February || sub.RenewalDate.Month() == time.March,\n\t\t\t\t\t\t\"Carbon annual should handle Feb 29 appropriately for %s\", tt.description)\n\t\t\t\t\tassert.True(t, sub.RenewalDate.Day() >= 28 && sub.RenewalDate.Day() <= 29,\n\t\t\t\t\t\t\"Carbon should use Feb 28 or 29 for leap year for %s\", tt.description)\n\t\t\t\t} else {\n\t\t\t\t\t// Monthly should handle leap year transition\n\t\t\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()),\n\t\t\t\t\t\t\"Carbon should handle leap year transition for %s\", tt.description)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestSubscription_VersionedCalculation tests that versioning works correctly\nfunc TestSubscription_VersionedCalculation(t *testing.T) {\n\tstartTime := time.Date(2025, 1, 31, 10, 0, 0, 0, time.UTC)\n\n\t// Test V1 calculation\n\tsubV1 := &Subscription{\n\t\tSchedule:               \"Monthly\",\n\t\tStartDate:              &startTime,\n\t\tStatus:                 \"Active\",\n\t\tDateCalculationVersion: 1, // V1\n\t}\n\tsubV1.calculateNextRenewalDate()\n\n\t// Test V2 calculation\n\tsubV2 := &Subscription{\n\t\tSchedule:               \"Monthly\",\n\t\tStartDate:              &startTime,\n\t\tStatus:                 \"Active\",\n\t\tDateCalculationVersion: 2, // V2\n\t}\n\tsubV2.calculateNextRenewalDate()\n\n\t// Both should have renewal dates set\n\tassert.NotNil(t, subV1.RenewalDate, \"V1 should calculate renewal date\")\n\tassert.NotNil(t, subV2.RenewalDate, \"V2 should calculate renewal date\")\n\n\t// V2 should handle month-end dates better with Carbon's NoOverflow\n\t// Both should be in the future\n\tassert.True(t, subV1.RenewalDate.After(time.Now()), \"V1 renewal should be in future\")\n\tassert.True(t, subV2.RenewalDate.After(time.Now()), \"V2 renewal should be in future\")\n}\n\n// TestSubscription_CarbonLibraryFeatures tests specific Carbon library features\nfunc TestSubscription_CarbonLibraryFeatures(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tstartDate   string\n\t\tschedule    string\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname:        \"Carbon NoOverflow handles Feb 31st\",\n\t\t\tstartDate:   \"2025-01-31T10:00:00Z\",\n\t\t\tschedule:    \"Monthly\",\n\t\t\tdescription: \"Carbon AddMonthsNoOverflow should handle Jan 31 → Feb properly\",\n\t\t},\n\t\t{\n\t\t\tname:        \"Carbon handles leap year transitions\",\n\t\t\tstartDate:   \"2024-02-29T10:00:00Z\",\n\t\t\tschedule:    \"Annual\",\n\t\t\tdescription: \"Carbon should handle Feb 29 → Feb 28 in non-leap years\",\n\t\t},\n\t\t{\n\t\t\tname:        \"Carbon preserves time zones\",\n\t\t\tstartDate:   \"2025-01-15T10:00:00-05:00\", // EST timezone\n\t\t\tschedule:    \"Monthly\",\n\t\t\tdescription: \"Carbon should preserve timezone information\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstartTime, err := time.Parse(time.RFC3339, tt.startDate)\n\t\t\tassert.NoError(t, err, \"Failed to parse start date\")\n\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule:               tt.schedule,\n\t\t\t\tStartDate:              &startTime,\n\t\t\t\tStatus:                 \"Active\",\n\t\t\t\tDateCalculationVersion: 2, // Use V2 Carbon-based calculation\n\t\t\t}\n\n\t\t\tsub.calculateNextRenewalDate()\n\n\t\t\tassert.NotNil(t, sub.RenewalDate, tt.description)\n\t\t\tassert.True(t, sub.RenewalDate.After(time.Now()), \"Renewal should be in future\")\n\n\t\t\t// Test timezone preservation\n\t\t\tif tt.name == \"Carbon preserves time zones\" {\n\t\t\t\tassert.Equal(t, startTime.Location(), sub.RenewalDate.Location(),\n\t\t\t\t\t\"Timezone should be preserved\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSubscription_DisplaySchedule(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tschedule string\n\t\tinterval int\n\t\texpected string\n\t}{\n\t\t{\"Monthly default\", \"Monthly\", 1, \"Monthly\"},\n\t\t{\"Monthly zero interval\", \"Monthly\", 0, \"Monthly\"},\n\t\t{\"Annual default\", \"Annual\", 1, \"Annual\"},\n\t\t{\"Every 2 Years\", \"Annual\", 2, \"Every 2 Years\"},\n\t\t{\"Every 10 Years\", \"Annual\", 10, \"Every 10 Years\"},\n\t\t{\"Every 2 Weeks\", \"Weekly\", 2, \"Every 2 Weeks\"},\n\t\t{\"Every 6 Months\", \"Monthly\", 6, \"Every 6 Months\"},\n\t\t{\"Every 3 Days\", \"Daily\", 3, \"Every 3 Days\"},\n\t\t{\"Quarterly default\", \"Quarterly\", 1, \"Quarterly\"},\n\t\t{\"Every 2 Quarters\", \"Quarterly\", 2, \"Every 2 Quarters\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{Schedule: tt.schedule, ScheduleInterval: tt.interval}\n\t\t\tassert.Equal(t, tt.expected, sub.DisplaySchedule())\n\t\t})\n\t}\n}\n\nfunc TestSubscription_CostWithInterval(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tschedule        string\n\t\tinterval        int\n\t\tcost            float64\n\t\texpectedAnnual  float64\n\t\texpectedMonthly float64\n\t}{\n\t\t{\"Monthly interval=1\", \"Monthly\", 1, 10.00, 120.00, 10.00},\n\t\t{\"Monthly interval=2\", \"Monthly\", 2, 10.00, 60.00, 5.00},\n\t\t{\"Annual interval=1\", \"Annual\", 1, 120.00, 120.00, 10.00},\n\t\t{\"Annual interval=2\", \"Annual\", 2, 120.00, 60.00, 5.00},\n\t\t{\"Annual interval=10\", \"Annual\", 10, 200.00, 20.00, 200.0 / 120.0},\n\t\t{\"Weekly interval=2\", \"Weekly\", 2, 10.00, 260.00, 10.0 * 4.33 / 2},\n\t\t{\"Daily interval=1\", \"Daily\", 1, 1.00, 365.00, 30.44},\n\t\t{\"Quarterly interval=1\", \"Quarterly\", 1, 30.00, 120.00, 10.00},\n\t\t{\"Quarterly interval=2\", \"Quarterly\", 2, 30.00, 60.00, 5.00},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{Schedule: tt.schedule, ScheduleInterval: tt.interval, Cost: tt.cost}\n\t\t\tassert.InDelta(t, tt.expectedAnnual, sub.AnnualCost(), 0.01, \"AnnualCost\")\n\t\t\tassert.InDelta(t, tt.expectedMonthly, sub.MonthlyCost(), 0.01, \"MonthlyCost\")\n\t\t})\n\t}\n}\n\nfunc TestSubscription_RenewalDateWithInterval(t *testing.T) {\n\tnow := time.Now()\n\tpastStart := now.AddDate(0, 0, -10) // 10 days ago\n\n\ttests := []struct {\n\t\tname     string\n\t\tschedule string\n\t\tinterval int\n\t\tstart    *time.Time\n\t}{\n\t\t{\"Every 2 Years\", \"Annual\", 2, &pastStart},\n\t\t{\"Every 2 Weeks\", \"Weekly\", 2, &pastStart},\n\t\t{\"Every 3 Months\", \"Monthly\", 3, &pastStart},\n\t\t{\"Every 5 Years from now\", \"Annual\", 5, nil},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &Subscription{\n\t\t\t\tSchedule:         tt.schedule,\n\t\t\t\tScheduleInterval: tt.interval,\n\t\t\t\tStartDate:        tt.start,\n\t\t\t\tStatus:           \"Active\",\n\t\t\t}\n\n\t\t\tsub.calculateNextRenewalDate()\n\t\t\tassert.NotNil(t, sub.RenewalDate)\n\t\t\tassert.True(t, sub.RenewalDate.After(now), \"Renewal should be in the future\")\n\n\t\t\t// Verify interval is respected: e.g. \"Every 2 Weeks\" from 10 days ago should be 4 days from now (14-10=4)\n\t\t\tif tt.schedule == \"Weekly\" && tt.interval == 2 && tt.start != nil {\n\t\t\t\tdaysDiff := sub.RenewalDate.Sub(pastStart).Hours() / 24\n\t\t\t\tassert.InDelta(t, 14, daysDiff, 1, \"Every 2 Weeks should be ~14 days from start\")\n\t\t\t}\n\t\t\tif tt.schedule == \"Annual\" && tt.interval == 5 && tt.start == nil {\n\t\t\t\tyearsDiff := sub.RenewalDate.Year() - now.Year()\n\t\t\t\tassert.Equal(t, 5, yearsDiff, \"Every 5 Years from now should be 5 years out\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSubscription_RenewalDateV2WithInterval(t *testing.T) {\n\tpastStart := time.Now().AddDate(-1, 0, 0) // 1 year ago\n\n\tsub := &Subscription{\n\t\tSchedule:               \"Annual\",\n\t\tScheduleInterval:       2,\n\t\tStartDate:              &pastStart,\n\t\tStatus:                 \"Active\",\n\t\tDateCalculationVersion: 2,\n\t}\n\n\tsub.calculateNextRenewalDate()\n\tassert.NotNil(t, sub.RenewalDate)\n\tassert.True(t, sub.RenewalDate.After(time.Now()))\n\n\t// 1 year ago + 2 years = 1 year from now\n\texpectedYear := pastStart.Year() + 2\n\tassert.Equal(t, expectedYear, sub.RenewalDate.Year(), \"Every 2 Years V2 should be 2 years from start\")\n}\n\n"
  },
  {
    "path": "internal/repository/category.go",
    "content": "package repository\n\nimport (\n\t\"subtrackr/internal/models\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype CategoryRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewCategoryRepository(db *gorm.DB) *CategoryRepository {\n\treturn &CategoryRepository{db: db}\n}\n\nfunc (r *CategoryRepository) Create(category *models.Category) (*models.Category, error) {\n\tif err := r.db.Create(category).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn category, nil\n}\n\nfunc (r *CategoryRepository) GetAll() ([]models.Category, error) {\n\tvar categories []models.Category\n\tif err := r.db.Order(\"name ASC\").Find(&categories).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn categories, nil\n}\n\nfunc (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {\n\tvar category models.Category\n\tif err := r.db.First(&category, id).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &category, nil\n}\n\nfunc (r *CategoryRepository) Update(id uint, category *models.Category) (*models.Category, error) {\n\tif err := r.db.Model(&models.Category{}).Where(\"id = ?\", id).Updates(category).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn r.GetByID(id)\n}\n\nfunc (r *CategoryRepository) Delete(id uint) error {\n\treturn r.db.Delete(&models.Category{}, id).Error\n}\n\nfunc (r *CategoryRepository) GetByName(name string) (*models.Category, error) {\n\tvar category models.Category\n\tif err := r.db.Where(\"name = ?\", name).First(&category).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &category, nil\n}\n\nfunc (r *CategoryRepository) HasSubscriptions(id uint) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&models.Subscription{}).Where(\"category_id = ?\", id).Count(&count).Error\n\treturn count > 0, err\n}\n"
  },
  {
    "path": "internal/repository/exchange_rate.go",
    "content": "package repository\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype ExchangeRateRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewExchangeRateRepository(db *gorm.DB) *ExchangeRateRepository {\n\treturn &ExchangeRateRepository{db: db}\n}\n\n// GetRate retrieves the exchange rate for a specific currency pair\nfunc (r *ExchangeRateRepository) GetRate(baseCurrency, targetCurrency string) (*models.ExchangeRate, error) {\n\tif baseCurrency == targetCurrency {\n\t\t// Return rate of 1.0 for same currency\n\t\treturn &models.ExchangeRate{\n\t\t\tBaseCurrency: baseCurrency,\n\t\t\tCurrency:     targetCurrency,\n\t\t\tRate:         1.0,\n\t\t\tDate:         time.Now(),\n\t\t}, nil\n\t}\n\n\tvar rate models.ExchangeRate\n\terr := r.db.Where(\"base_currency = ? AND currency = ?\", baseCurrency, targetCurrency).\n\t\tOrder(\"date DESC\").\n\t\tFirst(&rate).Error\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &rate, nil\n}\n\n// SaveRates saves multiple exchange rates\nfunc (r *ExchangeRateRepository) SaveRates(rates []models.ExchangeRate) error {\n\treturn r.db.Create(&rates).Error\n}\n\n// GetLatestRates retrieves the latest exchange rates for a base currency\nfunc (r *ExchangeRateRepository) GetLatestRates(baseCurrency string) ([]models.ExchangeRate, error) {\n\tvar rates []models.ExchangeRate\n\n\t// Get the latest rate for each target currency\n\tsubQuery := r.db.Model(&models.ExchangeRate{}).\n\t\tSelect(\"currency, MAX(date) as latest_date\").\n\t\tWhere(\"base_currency = ?\", baseCurrency).\n\t\tGroup(\"currency\")\n\n\terr := r.db.Joins(\"JOIN (?) as latest ON exchange_rates.currency = latest.currency AND exchange_rates.date = latest.latest_date\", subQuery).\n\t\tWhere(\"base_currency = ?\", baseCurrency).\n\t\tFind(&rates).Error\n\n\treturn rates, err\n}\n\n// DeleteStaleRates removes exchange rates older than the specified duration\nfunc (r *ExchangeRateRepository) DeleteStaleRates(olderThan time.Duration) error {\n\tcutoff := time.Now().Add(-olderThan)\n\treturn r.db.Where(\"date < ?\", cutoff).Delete(&models.ExchangeRate{}).Error\n}"
  },
  {
    "path": "internal/repository/settings.go",
    "content": "package repository\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype SettingsRepository struct {\n\tdb *gorm.DB\n}\n\nfunc NewSettingsRepository(db *gorm.DB) *SettingsRepository {\n\treturn &SettingsRepository{db: db}\n}\n\n// Set stores or updates a setting\nfunc (r *SettingsRepository) Set(key, value string) error {\n\tvar setting models.Settings\n\t\n\t// Try to find existing setting\n\terr := r.db.Where(\"key = ?\", key).First(&setting).Error\n\tif err == gorm.ErrRecordNotFound {\n\t\t// Create new setting\n\t\tsetting = models.Settings{\n\t\t\tKey:   key,\n\t\t\tValue: value,\n\t\t}\n\t\treturn r.db.Create(&setting).Error\n\t} else if err != nil {\n\t\treturn err\n\t}\n\t\n\t// Update existing setting\n\tsetting.Value = value\n\treturn r.db.Save(&setting).Error\n}\n\n// Get retrieves a setting value\nfunc (r *SettingsRepository) Get(key string) (string, error) {\n\tvar setting models.Settings\n\terr := r.db.Where(\"key = ?\", key).First(&setting).Error\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn setting.Value, nil\n}\n\n// Delete removes a setting\nfunc (r *SettingsRepository) Delete(key string) error {\n\treturn r.db.Where(\"key = ?\", key).Delete(&models.Settings{}).Error\n}\n\n// GetAll retrieves all settings\nfunc (r *SettingsRepository) GetAll() ([]models.Settings, error) {\n\tvar settings []models.Settings\n\terr := r.db.Find(&settings).Error\n\treturn settings, err\n}\n\n// CreateAPIKey creates a new API key\nfunc (r *SettingsRepository) CreateAPIKey(apiKey *models.APIKey) (*models.APIKey, error) {\n\tif err := r.db.Create(apiKey).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn apiKey, nil\n}\n\n// GetAllAPIKeys retrieves all API keys\nfunc (r *SettingsRepository) GetAllAPIKeys() ([]models.APIKey, error) {\n\tvar keys []models.APIKey\n\terr := r.db.Order(\"created_at DESC\").Find(&keys).Error\n\treturn keys, err\n}\n\n// GetAPIKeyByKey retrieves an API key by its key value\nfunc (r *SettingsRepository) GetAPIKeyByKey(key string) (*models.APIKey, error) {\n\tvar apiKey models.APIKey\n\terr := r.db.Where(\"key = ?\", key).First(&apiKey).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &apiKey, nil\n}\n\n// DeleteAPIKey deletes an API key\nfunc (r *SettingsRepository) DeleteAPIKey(id uint) error {\n\treturn r.db.Delete(&models.APIKey{}, id).Error\n}\n\n// UpdateAPIKeyUsage updates the usage stats for an API key\nfunc (r *SettingsRepository) UpdateAPIKeyUsage(id uint) error {\n\tnow := time.Now()\n\treturn r.db.Model(&models.APIKey{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"last_used\": now,\n\t\t\"usage_count\": gorm.Expr(\"usage_count + ?\", 1),\n\t}).Error\n}"
  },
  {
    "path": "internal/repository/subscription.go",
    "content": "package repository\n\nimport (\n\t\"strings\"\n\t\"subtrackr/internal/models\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype SubscriptionRepository struct {\n\tdb              *gorm.DB\n\thasLegacyColumn *bool\n}\n\nfunc NewSubscriptionRepository(db *gorm.DB) *SubscriptionRepository {\n\treturn &SubscriptionRepository{db: db}\n}\n\nfunc (r *SubscriptionRepository) checkLegacyColumn() bool {\n\tif r.hasLegacyColumn != nil {\n\t\treturn *r.hasLegacyColumn\n\t}\n\n\tvar exists bool\n\tr.db.Raw(\"SELECT COUNT(*) > 0 FROM pragma_table_info('subscriptions') WHERE name='category'\").Scan(&exists)\n\tr.hasLegacyColumn = &exists\n\treturn exists\n}\n\nfunc (r *SubscriptionRepository) Create(subscription *models.Subscription) (*models.Subscription, error) {\n\t// Check if the old category column exists (for legacy schema support)\n\tcolumnExists := r.checkLegacyColumn()\n\n\tif columnExists && subscription.CategoryID > 0 {\n\t\t// For legacy schema, we need to populate the old category column\n\t\tvar category models.Category\n\t\tif err := r.db.First(&category, subscription.CategoryID).Error; err == nil {\n\t\t\t// Use transaction for thread safety\n\t\t\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\t\t\tresult := tx.Exec(`\n\t\t\t\t\tINSERT INTO subscriptions (\n\t\t\t\t\t\tname, cost, schedule, schedule_interval, status, category_id, category, original_currency,\n\t\t\t\t\t\tpayment_method, account, start_date, renewal_date,\n\t\t\t\t\t\tcancellation_date, url, icon_url, notes, usage, reminder_enabled,\n\t\t\t\t\t\tdate_calculation_version, created_at, updated_at\n\t\t\t\t\t) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\t\t\tsubscription.Name, subscription.Cost, subscription.Schedule, subscription.ScheduleInterval,\n\t\t\t\t\tsubscription.Status, subscription.CategoryID, category.Name, subscription.OriginalCurrency,\n\t\t\t\t\tsubscription.PaymentMethod, subscription.Account,\n\t\t\t\t\tsubscription.StartDate, subscription.RenewalDate,\n\t\t\t\t\tsubscription.CancellationDate, subscription.URL, subscription.IconURL,\n\t\t\t\t\tsubscription.Notes, subscription.Usage, subscription.ReminderEnabled,\n\t\t\t\t\tsubscription.DateCalculationVersion,\n\t\t\t\t\ttime.Now(), time.Now())\n\n\t\t\t\tif result.Error != nil {\n\t\t\t\t\treturn result.Error\n\t\t\t\t}\n\n\t\t\t\t// Get the last inserted ID within the transaction\n\t\t\t\tvar lastID int64\n\t\t\t\tif err := tx.Raw(\"SELECT last_insert_rowid()\").Scan(&lastID).Error; err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tsubscription.ID = uint(lastID)\n\t\t\t\treturn nil\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\treturn subscription, nil\n\t\t}\n\t}\n\n\t// Normal creation for migrated schema\n\tif err := r.db.Create(subscription).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn subscription, nil\n}\n\nfunc (r *SubscriptionRepository) GetAll() ([]models.Subscription, error) {\n\tvar subscriptions []models.Subscription\n\tif err := r.db.Preload(\"Category\").Order(\"created_at DESC\").Find(&subscriptions).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn subscriptions, nil\n}\n\n// GetAllSorted returns all subscriptions sorted by the specified column and order\n// sortBy: name, cost, status, renewal_date, schedule, category, created_at\n// order: asc, desc\nfunc (r *SubscriptionRepository) GetAllSorted(sortBy, order string) ([]models.Subscription, error) {\n\tvar subscriptions []models.Subscription\n\tquery := r.db.Preload(\"Category\")\n\n\t// Validate and set sort column\n\tvalidSortColumns := map[string]string{\n\t\t\"name\":         \"name\",\n\t\t\"cost\":         \"cost\",\n\t\t\"status\":       \"status\",\n\t\t\"renewal_date\": \"renewal_date\",\n\t\t\"schedule\":     \"schedule\",\n\t\t\"category\":     \"categories.name\",\n\t\t\"created_at\":   \"created_at\",\n\t}\n\n\tsortColumn, ok := validSortColumns[sortBy]\n\tif !ok {\n\t\tsortColumn = \"created_at\" // default\n\t}\n\n\t// Validate order\n\tif order != \"asc\" && order != \"desc\" {\n\t\torder = \"desc\" // default\n\t}\n\n\t// Build order clause\n\torderClause := sortColumn + \" \" + strings.ToUpper(order)\n\n\t// Special handling for category (requires join)\n\tif sortBy == \"category\" {\n\t\tquery = query.Joins(\"LEFT JOIN categories ON subscriptions.category_id = categories.id\")\n\t}\n\n\tif err := query.Order(orderClause).Find(&subscriptions).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn subscriptions, nil\n}\n\nfunc (r *SubscriptionRepository) GetByID(id uint) (*models.Subscription, error) {\n\tvar subscription models.Subscription\n\tif err := r.db.Preload(\"Category\").First(&subscription, id).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &subscription, nil\n}\n\nfunc (r *SubscriptionRepository) Update(id uint, subscription *models.Subscription) (*models.Subscription, error) {\n\t// First, get the existing subscription\n\tvar existing models.Subscription\n\tif err := r.db.First(&existing, id).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Check if the old category column exists\n\tcolumnExists := r.checkLegacyColumn()\n\n\t// Update the existing subscription with new values\n\texisting.Name = subscription.Name\n\texisting.Cost = subscription.Cost\n\texisting.Schedule = subscription.Schedule\n\texisting.ScheduleInterval = subscription.ScheduleInterval\n\texisting.Status = subscription.Status\n\texisting.CategoryID = subscription.CategoryID\n\texisting.OriginalCurrency = subscription.OriginalCurrency\n\texisting.PaymentMethod = subscription.PaymentMethod\n\texisting.Account = subscription.Account\n\texisting.StartDate = subscription.StartDate\n\texisting.LastReminderSent = subscription.LastReminderSent\n\texisting.LastReminderRenewalDate = subscription.LastReminderRenewalDate\n\texisting.LastCancellationReminderSent = subscription.LastCancellationReminderSent\n\texisting.LastCancellationReminderDate = subscription.LastCancellationReminderDate\n\texisting.RenewalDate = subscription.RenewalDate\n\texisting.CancellationDate = subscription.CancellationDate\n\texisting.URL = subscription.URL\n\texisting.IconURL = subscription.IconURL\n\texisting.Notes = subscription.Notes\n\texisting.Usage = subscription.Usage\n\texisting.ReminderEnabled = subscription.ReminderEnabled\n\n\tif columnExists && subscription.CategoryID > 0 {\n\t\t// For legacy schema, we need to update the old category column too\n\t\tvar category models.Category\n\t\tif err := r.db.First(&category, subscription.CategoryID).Error; err == nil {\n\t\t\t// We need to manually set the category name for legacy schema\n\t\t\tupdates := map[string]interface{}{\n\t\t\t\t\"name\":                       existing.Name,\n\t\t\t\t\"cost\":                       existing.Cost,\n\t\t\t\t\"schedule\":                   existing.Schedule,\n\t\t\t\t\"schedule_interval\":          existing.ScheduleInterval,\n\t\t\t\t\"status\":                     existing.Status,\n\t\t\t\t\"category_id\":                existing.CategoryID,\n\t\t\t\t\"category\":                   category.Name,\n\t\t\t\t\"original_currency\":          existing.OriginalCurrency,\n\t\t\t\t\"payment_method\":             existing.PaymentMethod,\n\t\t\t\t\"account\":                    existing.Account,\n\t\t\t\t\"start_date\":                 existing.StartDate,\n\t\t\t\t\"renewal_date\":               existing.RenewalDate,\n\t\t\t\t\"cancellation_date\":          existing.CancellationDate,\n\t\t\t\t\"url\":                        existing.URL,\n\t\t\t\t\"icon_url\":                   existing.IconURL,\n\t\t\t\t\"notes\":                      existing.Notes,\n\t\t\t\t\"usage\":                      existing.Usage,\n\t\t\t\t\"last_reminder_sent\":         existing.LastReminderSent,\n\t\t\t\t\"last_reminder_renewal_date\": existing.LastReminderRenewalDate,\n\t\t\t\t\"reminder_enabled\":                    existing.ReminderEnabled,\n\t\t\t\t\"last_cancellation_reminder_sent\":     existing.LastCancellationReminderSent,\n\t\t\t\t\"last_cancellation_reminder_date\":     existing.LastCancellationReminderDate,\n\t\t\t\t\"updated_at\":                          time.Now(),\n\t\t\t}\n\t\t\tif err := r.db.Model(&existing).Where(\"id = ?\", id).Updates(updates).Error; err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn r.GetByID(id)\n\t\t}\n\t}\n\n\t// The existing record already has the correct ID from the First() query above\n\t// Use Save which will update only the record with matching primary key\n\t// This also properly triggers the BeforeUpdate hook\n\tif err := r.db.Save(&existing).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Reload to get any changes from hooks\n\treturn r.GetByID(id)\n}\n\nfunc (r *SubscriptionRepository) Delete(id uint) error {\n\treturn r.db.Delete(&models.Subscription{}, id).Error\n}\n\nfunc (r *SubscriptionRepository) Count() int64 {\n\tvar count int64\n\tr.db.Model(&models.Subscription{}).Count(&count)\n\treturn count\n}\n\nfunc (r *SubscriptionRepository) GetActiveSubscriptions() ([]models.Subscription, error) {\n\tvar subscriptions []models.Subscription\n\tif err := r.db.Preload(\"Category\").Where(\"status = ?\", \"Active\").Find(&subscriptions).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn subscriptions, nil\n}\n\nfunc (r *SubscriptionRepository) GetCancelledSubscriptions() ([]models.Subscription, error) {\n\tvar subscriptions []models.Subscription\n\tif err := r.db.Preload(\"Category\").Where(\"status = ?\", \"Cancelled\").Find(&subscriptions).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn subscriptions, nil\n}\n\nfunc (r *SubscriptionRepository) GetUpcomingRenewals(days int) ([]models.Subscription, error) {\n\tvar subscriptions []models.Subscription\n\tendDate := time.Now().AddDate(0, 0, days)\n\n\tif err := r.db.Where(\"status = ? AND renewal_date IS NOT NULL AND renewal_date BETWEEN ? AND ?\",\n\t\t\"Active\", time.Now(), endDate).Find(&subscriptions).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn subscriptions, nil\n}\n\nfunc (r *SubscriptionRepository) GetUpcomingCancellations(days int) ([]models.Subscription, error) {\n\tvar subscriptions []models.Subscription\n\tendDate := time.Now().AddDate(0, 0, days)\n\n\tif err := r.db.Where(\"status = ? AND cancellation_date IS NOT NULL AND cancellation_date BETWEEN ? AND ?\",\n\t\t\"Cancelled\", time.Now(), endDate).Find(&subscriptions).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn subscriptions, nil\n}\n\nfunc (r *SubscriptionRepository) GetCategoryStats() ([]models.CategoryStat, error) {\n\tvar stats []models.CategoryStat\n\tif err := r.db.Table(\"subscriptions\").\n\t\tSelect(\"categories.name as category, SUM(CASE WHEN subscriptions.schedule = 'Annual' THEN subscriptions.cost/12 WHEN subscriptions.schedule = 'Quarterly' THEN subscriptions.cost/3 WHEN subscriptions.schedule = 'Monthly' THEN subscriptions.cost WHEN subscriptions.schedule = 'Weekly' THEN subscriptions.cost*4.33 WHEN subscriptions.schedule = 'Daily' THEN subscriptions.cost*30.44 ELSE subscriptions.cost END) as amount, COUNT(*) as count\").\n\t\tJoins(\"left join categories on subscriptions.category_id = categories.id\").\n\t\tWhere(\"subscriptions.status = ?\", \"Active\").\n\t\tGroup(\"categories.name\").\n\t\tScan(&stats).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn stats, nil\n}\n"
  },
  {
    "path": "internal/service/category.go",
    "content": "package service\n\nimport (\n\t\"errors\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n)\n\n// CategoryService provides business logic for categories\ntype CategoryService struct {\n\trepo *repository.CategoryRepository\n}\n\nfunc NewCategoryService(repo *repository.CategoryRepository) *CategoryService {\n\treturn &CategoryService{repo: repo}\n}\n\nfunc (s *CategoryService) Create(category *models.Category) (*models.Category, error) {\n\treturn s.repo.Create(category)\n}\n\nfunc (s *CategoryService) GetAll() ([]models.Category, error) {\n\treturn s.repo.GetAll()\n}\n\nfunc (s *CategoryService) GetByID(id uint) (*models.Category, error) {\n\treturn s.repo.GetByID(id)\n}\n\nfunc (s *CategoryService) Update(id uint, category *models.Category) (*models.Category, error) {\n\treturn s.repo.Update(id, category)\n}\n\nfunc (s *CategoryService) GetByName(name string) (*models.Category, error) {\n\treturn s.repo.GetByName(name)\n}\n\nfunc (s *CategoryService) Delete(id uint) error {\n\t// Check if category has any subscriptions\n\thasSubscriptions, err := s.repo.HasSubscriptions(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif hasSubscriptions {\n\t\treturn errors.New(\"cannot delete category with active subscriptions\")\n\t}\n\treturn s.repo.Delete(id)\n}\n"
  },
  {
    "path": "internal/service/currency.go",
    "content": "package service\n\nimport (\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"time\"\n)\n\n// CurrencyInfo holds metadata for a supported currency\ntype CurrencyInfo struct {\n\tCode   string `json:\"code\"`\n\tSymbol string `json:\"symbol\"`\n\tName   string `json:\"name\"`\n}\n\n// BuiltinCurrencies is the comprehensive list of supported currencies\nvar BuiltinCurrencies = []CurrencyInfo{\n\t{Code: \"USD\", Symbol: \"$\", Name: \"US Dollar\"},\n\t{Code: \"EUR\", Symbol: \"€\", Name: \"Euro\"},\n\t{Code: \"GBP\", Symbol: \"£\", Name: \"British Pound\"},\n\t{Code: \"AUD\", Symbol: \"A$\", Name: \"Australian Dollar\"},\n\t{Code: \"CAD\", Symbol: \"C$\", Name: \"Canadian Dollar\"},\n\t{Code: \"NZD\", Symbol: \"NZ$\", Name: \"New Zealand Dollar\"},\n\t{Code: \"JPY\", Symbol: \"¥\", Name: \"Japanese Yen\"},\n\t{Code: \"CHF\", Symbol: \"Fr.\", Name: \"Swiss Franc\"},\n\t{Code: \"CNY\", Symbol: \"¥\", Name: \"Chinese Yuan\"},\n\t{Code: \"SEK\", Symbol: \"kr\", Name: \"Swedish Krona\"},\n\t{Code: \"NOK\", Symbol: \"kr\", Name: \"Norwegian Krone\"},\n\t{Code: \"DKK\", Symbol: \"kr\", Name: \"Danish Krone\"},\n\t{Code: \"INR\", Symbol: \"₹\", Name: \"Indian Rupee\"},\n\t{Code: \"RUB\", Symbol: \"₽\", Name: \"Russian Ruble\"},\n\t{Code: \"BRL\", Symbol: \"R$\", Name: \"Brazilian Real\"},\n\t{Code: \"PLN\", Symbol: \"zł\", Name: \"Polish Zloty\"},\n\t{Code: \"KRW\", Symbol: \"₩\", Name: \"South Korean Won\"},\n\t{Code: \"SGD\", Symbol: \"S$\", Name: \"Singapore Dollar\"},\n\t{Code: \"HKD\", Symbol: \"HK$\", Name: \"Hong Kong Dollar\"},\n\t{Code: \"MXN\", Symbol: \"Mex$\", Name: \"Mexican Peso\"},\n\t{Code: \"ZAR\", Symbol: \"R\", Name: \"South African Rand\"},\n\t{Code: \"TRY\", Symbol: \"₺\", Name: \"Turkish Lira\"},\n\t{Code: \"THB\", Symbol: \"฿\", Name: \"Thai Baht\"},\n\t{Code: \"COP\", Symbol: \"COL$\", Name: \"Colombian Peso\"},\n\t{Code: \"BDT\", Symbol: \"৳\", Name: \"Bangladeshi Taka\"},\n\t{Code: \"IDR\", Symbol: \"Rp\", Name: \"Indonesian Rupiah\"},\n\t{Code: \"PHP\", Symbol: \"₱\", Name: \"Philippine Peso\"},\n\t{Code: \"TWD\", Symbol: \"NT$\", Name: \"New Taiwan Dollar\"},\n\t{Code: \"MYR\", Symbol: \"RM\", Name: \"Malaysian Ringgit\"},\n\t{Code: \"AED\", Symbol: \"د.إ\", Name: \"UAE Dirham\"},\n\t{Code: \"SAR\", Symbol: \"﷼\", Name: \"Saudi Riyal\"},\n\t{Code: \"ILS\", Symbol: \"₪\", Name: \"Israeli Shekel\"},\n\t{Code: \"CZK\", Symbol: \"Kč\", Name: \"Czech Koruna\"},\n\t{Code: \"HUF\", Symbol: \"Ft\", Name: \"Hungarian Forint\"},\n\t{Code: \"RON\", Symbol: \"lei\", Name: \"Romanian Leu\"},\n}\n\n// currencyInfoMap provides O(1) lookup by code\nvar currencyInfoMap map[string]CurrencyInfo\n\n// SupportedCurrencies is derived from BuiltinCurrencies for backward compatibility\nvar SupportedCurrencies []string\n\nfunc init() {\n\tcurrencyInfoMap = make(map[string]CurrencyInfo, len(BuiltinCurrencies))\n\tSupportedCurrencies = make([]string, len(BuiltinCurrencies))\n\tfor i, c := range BuiltinCurrencies {\n\t\tcurrencyInfoMap[c.Code] = c\n\t\tSupportedCurrencies[i] = c.Code\n\t}\n}\n\n// GetCurrencyInfo returns metadata for a currency code, with a fallback for unknown codes\nfunc GetCurrencyInfo(code string) CurrencyInfo {\n\tif info, ok := currencyInfoMap[code]; ok {\n\t\treturn info\n\t}\n\treturn CurrencyInfo{Code: code, Symbol: code, Name: code}\n}\n\n// GetAvailableCurrencies returns all supported currencies\nfunc GetAvailableCurrencies() []CurrencyInfo {\n\treturn BuiltinCurrencies\n}\n\n// supportedCurrencySymbols returns the currencies as a comma-separated string for API calls\nfunc supportedCurrencySymbols() string {\n\treturn strings.Join(SupportedCurrencies, \",\")\n}\n\ntype CurrencyService struct {\n\trepo   *repository.ExchangeRateRepository\n\tapiKey string\n}\n\ntype FixerResponse struct {\n\tSuccess   bool               `json:\"success\"`\n\tTimestamp int64              `json:\"timestamp\"`\n\tBase      string             `json:\"base\"`\n\tDate      string             `json:\"date\"`\n\tRates     map[string]float64 `json:\"rates\"`\n\tError     *FixerError        `json:\"error,omitempty\"`\n}\n\ntype FixerError struct {\n\tCode int    `json:\"code\"`\n\tInfo string `json:\"info\"`\n}\n\nfunc NewCurrencyService(repo *repository.ExchangeRateRepository) *CurrencyService {\n\treturn &CurrencyService{\n\t\trepo:   repo,\n\t\tapiKey: os.Getenv(\"FIXER_API_KEY\"),\n\t}\n}\n\n// IsEnabled returns true if currency conversion is enabled (API key is set)\nfunc (s *CurrencyService) IsEnabled() bool {\n\treturn s.apiKey != \"\"\n}\n\n// GetExchangeRate retrieves exchange rate between two currencies\nfunc (s *CurrencyService) GetExchangeRate(fromCurrency, toCurrency string) (float64, error) {\n\tif fromCurrency == toCurrency {\n\t\treturn 1.0, nil\n\t}\n\n\t// Try to get cached rate first\n\trate, err := s.repo.GetRate(fromCurrency, toCurrency)\n\tif err == nil && !rate.IsStale() {\n\t\treturn rate.Rate, nil\n\t}\n\n\t// If no API key, return error\n\tif !s.IsEnabled() {\n\t\treturn 0, fmt.Errorf(\"currency conversion not available - no Fixer API key configured\")\n\t}\n\n\t// Fetch from Fixer.io API\n\treturn s.fetchAndCacheRates(fromCurrency, toCurrency)\n}\n\n// ConvertAmount converts an amount from one currency to another\nfunc (s *CurrencyService) ConvertAmount(amount float64, fromCurrency, toCurrency string) (float64, error) {\n\trate, err := s.GetExchangeRate(fromCurrency, toCurrency)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn amount * rate, nil\n}\n\n// fetchAndCacheRates fetches rates from Fixer.io and caches them.\n// Note: Free Fixer.io plan only supports EUR base, so baseCurrency parameter\n// is used for cross-rate calculations but API always fetches with EUR base.\nfunc (s *CurrencyService) fetchAndCacheRates(baseCurrency, targetCurrency string) (float64, error) {\n\t// Use supported currencies as comma-separated string\n\tsymbols := supportedCurrencySymbols()\n\n\t// Free Fixer.io plan only supports EUR as base currency\n\t// Always fetch with EUR as base and calculate cross-rates if needed\n\tapiURL := fmt.Sprintf(\"https://data.fixer.io/api/latest?access_key=%s&base=EUR&symbols=%s\",\n\t\ts.apiKey, symbols)\n\n\t// Validate URL to ensure we're calling the expected API\n\tparsedURL, err := url.Parse(apiURL)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"invalid API URL: %w\", err)\n\t}\n\tif parsedURL.Host != \"data.fixer.io\" {\n\t\treturn 0, fmt.Errorf(\"unauthorized API host: %s\", parsedURL.Host)\n\t}\n\n\t// Configure HTTP client with security and timeout settings\n\tclient := &http.Client{\n\t\tTimeout: 10 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{\n\t\t\t\tMinVersion: tls.VersionTLS12, // Require TLS 1.2 or higher\n\t\t\t},\n\t\t},\n\t}\n\tresp, err := client.Get(apiURL)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to fetch exchange rates: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar fixerResp FixerResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&fixerResp); err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to decode response: %w\", err)\n\t}\n\n\tif !fixerResp.Success {\n\t\tif fixerResp.Error != nil {\n\t\t\treturn 0, fmt.Errorf(\"Fixer API error: %s\", fixerResp.Error.Info)\n\t\t}\n\t\treturn 0, fmt.Errorf(\"Fixer API request failed\")\n\t}\n\n\t// Parse date\n\trateDate := time.Unix(fixerResp.Timestamp, 0)\n\n\t// Cache all rates (always with EUR as base from Fixer.io)\n\tvar ratesToSave []models.ExchangeRate\n\n\t// Add EUR to EUR rate (1.0)\n\tratesToSave = append(ratesToSave, models.ExchangeRate{\n\t\tBaseCurrency: \"EUR\",\n\t\tCurrency:     \"EUR\",\n\t\tRate:         1.0,\n\t\tDate:         rateDate,\n\t})\n\n\t// Add all other rates from API\n\tfor currency, rate := range fixerResp.Rates {\n\t\tratesToSave = append(ratesToSave, models.ExchangeRate{\n\t\t\tBaseCurrency: \"EUR\",\n\t\t\tCurrency:     currency,\n\t\t\tRate:         rate,\n\t\t\tDate:         rateDate,\n\t\t})\n\t}\n\n\tif len(ratesToSave) > 0 {\n\t\tif err := s.repo.SaveRates(ratesToSave); err != nil {\n\t\t\t// Log error but don't fail the request\n\t\t\tlog.Printf(\"Warning: failed to cache exchange rates: %v\", err)\n\t\t}\n\t}\n\n\t// Calculate the cross-rate if needed\n\tif baseCurrency == \"EUR\" {\n\t\t// Direct rate from EUR\n\t\tif rate, exists := fixerResp.Rates[targetCurrency]; exists {\n\t\t\treturn rate, nil\n\t\t}\n\t} else if targetCurrency == \"EUR\" {\n\t\t// Inverse rate to EUR\n\t\tif rate, exists := fixerResp.Rates[baseCurrency]; exists && rate != 0 {\n\t\t\treturn 1.0 / rate, nil\n\t\t}\n\t} else {\n\t\t// Cross-rate: base->EUR->target\n\t\tbaseToEur, exists1 := fixerResp.Rates[baseCurrency]\n\t\teurToTarget, exists2 := fixerResp.Rates[targetCurrency]\n\n\t\tif exists1 && exists2 && baseToEur != 0 {\n\t\t\t// Convert: (1/baseToEur) * eurToTarget = cross rate\n\t\t\treturn eurToTarget / baseToEur, nil\n\t\t}\n\t}\n\n\treturn 0, fmt.Errorf(\"exchange rate for %s to %s not available\", baseCurrency, targetCurrency)\n}\n\n// RefreshRates updates all exchange rates from the API\nfunc (s *CurrencyService) RefreshRates() error {\n\tif !s.IsEnabled() {\n\t\treturn fmt.Errorf(\"currency service not enabled\")\n\t}\n\n\t// Fetch rates once with EUR base (free Fixer.io plan only supports EUR base)\n\t// All cross-rates are calculated from this single API call\n\t_, err := s.fetchAndCacheRates(\"EUR\", \"USD\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to refresh rates: %w\", err)\n\t}\n\n\t// Clean up old rates (keep last 7 days)\n\treturn s.repo.DeleteStaleRates(7 * 24 * time.Hour)\n}\n"
  },
  {
    "path": "internal/service/currency_integration_test.go",
    "content": "package service\n\nimport (\n\t\"os\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc setupTestDB(t *testing.T) *gorm.DB {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\n\t// Migrate the schema\n\terr = db.AutoMigrate(&models.ExchangeRate{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\treturn db\n}\n\nfunc TestCurrencyService_Integration_IsEnabled(t *testing.T) {\n\tdb := setupTestDB(t)\n\trepo := repository.NewExchangeRateRepository(db)\n\n\ttests := []struct {\n\t\tname     string\n\t\tapiKey   string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"Enabled with API key\",\n\t\t\tapiKey:   \"test-api-key\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Disabled without API key\",\n\t\t\tapiKey:   \"\",\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Set or unset the environment variable\n\t\t\tif tt.apiKey != \"\" {\n\t\t\t\tos.Setenv(\"FIXER_API_KEY\", tt.apiKey)\n\t\t\t} else {\n\t\t\t\tos.Unsetenv(\"FIXER_API_KEY\")\n\t\t\t}\n\n\t\t\tservice := NewCurrencyService(repo)\n\t\t\tassert.Equal(t, tt.expected, service.IsEnabled())\n\t\t})\n\t}\n\n\t// Clean up\n\tos.Unsetenv(\"FIXER_API_KEY\")\n}\n\nfunc TestCurrencyService_Integration_ConvertAmount_SameCurrency(t *testing.T) {\n\tdb := setupTestDB(t)\n\trepo := repository.NewExchangeRateRepository(db)\n\tservice := NewCurrencyService(repo)\n\n\t// Test same currency conversion (should return same amount)\n\tamount := 100.0\n\tresult, err := service.ConvertAmount(amount, \"USD\", \"USD\")\n\n\tassert.NoError(t, err)\n\tassert.Equal(t, amount, result)\n}\n\nfunc TestCurrencyService_Integration_ConvertAmount_WithCachedRate(t *testing.T) {\n\tos.Setenv(\"FIXER_API_KEY\", \"test-key\")\n\tdefer os.Unsetenv(\"FIXER_API_KEY\")\n\n\tdb := setupTestDB(t)\n\trepo := repository.NewExchangeRateRepository(db)\n\tservice := NewCurrencyService(repo)\n\n\t// Create a cached rate\n\tcachedRate := &models.ExchangeRate{\n\t\tBaseCurrency: \"USD\",\n\t\tCurrency:     \"EUR\",\n\t\tRate:         0.85,\n\t\tDate:         time.Now(),\n\t}\n\n\terr := repo.SaveRates([]models.ExchangeRate{*cachedRate})\n\tassert.NoError(t, err)\n\n\tamount := 100.0\n\tresult, err := service.ConvertAmount(amount, \"USD\", \"EUR\")\n\n\tassert.NoError(t, err)\n\tassert.Equal(t, 85.0, result)\n}\n\nfunc TestCurrencyService_Integration_ConvertAmount_NoAPIKey(t *testing.T) {\n\tos.Unsetenv(\"FIXER_API_KEY\")\n\n\tdb := setupTestDB(t)\n\trepo := repository.NewExchangeRateRepository(db)\n\tservice := NewCurrencyService(repo)\n\n\tamount := 100.0\n\tresult, err := service.ConvertAmount(amount, \"USD\", \"EUR\")\n\n\tassert.Error(t, err)\n\tassert.Equal(t, 0.0, result)\n\tassert.Contains(t, err.Error(), \"currency conversion not available\")\n}\n\nfunc TestCurrencyService_Integration_ConvertAmount_InvalidAmount(t *testing.T) {\n\tos.Setenv(\"FIXER_API_KEY\", \"test-key\")\n\tdefer os.Unsetenv(\"FIXER_API_KEY\")\n\n\tdb := setupTestDB(t)\n\trepo := repository.NewExchangeRateRepository(db)\n\tservice := NewCurrencyService(repo)\n\n\t// Pre-cache a rate to avoid API calls\n\tcachedRate := models.ExchangeRate{\n\t\tBaseCurrency: \"USD\",\n\t\tCurrency:     \"EUR\",\n\t\tRate:         0.85,\n\t\tDate:         time.Now(),\n\t}\n\trepo.SaveRates([]models.ExchangeRate{cachedRate})\n\n\ttests := []struct {\n\t\tname     string\n\t\tamount   float64\n\t\texpected float64\n\t}{\n\t\t{\"Negative amount\", -100.0, -85.0}, // Negative amounts are converted\n\t\t{\"Zero amount\", 0.0, 0.0},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := service.ConvertAmount(tt.amount, \"USD\", \"EUR\")\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestCurrencyService_Integration_SupportedCurrencies(t *testing.T) {\n\tdb := setupTestDB(t)\n\trepo := repository.NewExchangeRateRepository(db)\n\tservice := NewCurrencyService(repo)\n\n\t// Test that common currencies are supported\n\tsupportedCurrencies := []string{\n\t\t\"USD\", \"EUR\", \"GBP\", \"CAD\", \"AUD\", \"JPY\", \"INR\",\n\t\t\"CHF\", \"SEK\", \"NOK\", \"DKK\", \"NZD\", \"SGD\", \"HKD\",\n\t}\n\n\tfor _, currency := range supportedCurrencies {\n\t\tt.Run(currency, func(t *testing.T) {\n\t\t\t// Test by attempting same-currency conversion (should always work)\n\t\t\tresult, err := service.ConvertAmount(100.0, currency, currency)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, 100.0, result)\n\t\t})\n\t}\n}\n\nfunc TestCurrencyService_Integration_BDTCurrency(t *testing.T) {\n\tdb := setupTestDB(t)\n\trepo := repository.NewExchangeRateRepository(db)\n\tservice := NewCurrencyService(repo)\n\n\t// Test BDT currency support\n\tt.Run(\"BDT same currency conversion\", func(t *testing.T) {\n\t\tresult, err := service.ConvertAmount(100.0, \"BDT\", \"BDT\")\n\t\tassert.NoError(t, err, \"BDT should be supported\")\n\t\tassert.Equal(t, 100.0, result, \"Same currency conversion should return same amount\")\n\t})\n\n\tt.Run(\"BDT in SupportedCurrencies list\", func(t *testing.T) {\n\t\tfound := false\n\t\tfor _, currency := range SupportedCurrencies {\n\t\t\tif currency == \"BDT\" {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tassert.True(t, found, \"BDT should be in SupportedCurrencies list\")\n\t})\n}\n\nfunc TestSettingsService_GetCurrencySymbol_BDT(t *testing.T) {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\n\terr = db.AutoMigrate(&models.Settings{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\n\t// Set currency to BDT\n\terr = settingsService.SetCurrency(\"BDT\")\n\tassert.NoError(t, err, \"Should be able to set BDT currency\")\n\n\t// Get currency symbol\n\tsymbol := settingsService.GetCurrencySymbol()\n\tassert.Equal(t, \"৳\", symbol, \"BDT currency symbol should be ৳\")\n\n\t// Verify currency is set correctly\n\tcurrency := settingsService.GetCurrency()\n\tassert.Equal(t, \"BDT\", currency, \"Currency should be BDT\")\n}\n\nfunc TestSettingsService_SetCurrency_BDT(t *testing.T) {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\n\terr = db.AutoMigrate(&models.Settings{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\n\ttests := []struct {\n\t\tname           string\n\t\tcurrency       string\n\t\tshouldSucceed  bool\n\t\texpectedSymbol string\n\t}{\n\t\t{\n\t\t\tname:           \"Valid BDT currency\",\n\t\t\tcurrency:       \"BDT\",\n\t\t\tshouldSucceed:  true,\n\t\t\texpectedSymbol: \"৳\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Invalid currency\",\n\t\t\tcurrency:      \"XYZ\",\n\t\t\tshouldSucceed: false,\n\t\t},\n\t\t{\n\t\t\tname:           \"Other valid currencies\",\n\t\t\tcurrency:       \"USD\",\n\t\t\tshouldSucceed:  true,\n\t\t\texpectedSymbol: \"$\",\n\t\t},\n\t\t{\n\t\t\tname:           \"EUR currency\",\n\t\t\tcurrency:       \"EUR\",\n\t\t\tshouldSucceed:  true,\n\t\t\texpectedSymbol: \"€\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := settingsService.SetCurrency(tt.currency)\n\t\t\tif tt.shouldSucceed {\n\t\t\t\tassert.NoError(t, err, \"Should succeed for valid currency\")\n\t\t\t\tif tt.expectedSymbol != \"\" {\n\t\t\t\t\tsymbol := settingsService.GetCurrencySymbol()\n\t\t\t\t\tassert.Equal(t, tt.expectedSymbol, symbol, \"Currency symbol should match\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tassert.Error(t, err, \"Should fail for invalid currency\")\n\t\t\t\tassert.Contains(t, err.Error(), \"invalid currency\", \"Error should mention invalid currency\")\n\t\t\t}\n\t\t})\n\t}\n}"
  },
  {
    "path": "internal/service/currency_test.go",
    "content": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc TestGetCurrencyInfo_KnownCurrencies(t *testing.T) {\n\ttests := []struct {\n\t\tcode           string\n\t\texpectedSymbol string\n\t\texpectedName   string\n\t}{\n\t\t{\"USD\", \"$\", \"US Dollar\"},\n\t\t{\"EUR\", \"€\", \"Euro\"},\n\t\t{\"GBP\", \"£\", \"British Pound\"},\n\t\t{\"JPY\", \"¥\", \"Japanese Yen\"},\n\t\t{\"INR\", \"₹\", \"Indian Rupee\"},\n\t\t{\"BRL\", \"R$\", \"Brazilian Real\"},\n\t\t{\"COP\", \"COL$\", \"Colombian Peso\"},\n\t\t{\"BDT\", \"৳\", \"Bangladeshi Taka\"},\n\t\t{\"AED\", \"د.إ\", \"UAE Dirham\"},\n\t\t{\"CZK\", \"Kč\", \"Czech Koruna\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.code, func(t *testing.T) {\n\t\t\tinfo := GetCurrencyInfo(tt.code)\n\t\t\tassert.Equal(t, tt.code, info.Code)\n\t\t\tassert.Equal(t, tt.expectedSymbol, info.Symbol)\n\t\t\tassert.Equal(t, tt.expectedName, info.Name)\n\t\t})\n\t}\n}\n\nfunc TestGetCurrencyInfo_UnknownCurrency(t *testing.T) {\n\tinfo := GetCurrencyInfo(\"XYZ\")\n\tassert.Equal(t, \"XYZ\", info.Code)\n\tassert.Equal(t, \"XYZ\", info.Symbol, \"Unknown currency should use code as symbol\")\n\tassert.Equal(t, \"XYZ\", info.Name, \"Unknown currency should use code as name\")\n}\n\nfunc TestGetCurrencyInfo_EmptyCode(t *testing.T) {\n\tinfo := GetCurrencyInfo(\"\")\n\tassert.Equal(t, \"\", info.Code)\n\tassert.Equal(t, \"\", info.Symbol)\n\tassert.Equal(t, \"\", info.Name)\n}\n\nfunc TestGetAvailableCurrencies(t *testing.T) {\n\tcurrencies := GetAvailableCurrencies()\n\n\tassert.Equal(t, len(BuiltinCurrencies), len(currencies))\n\tassert.True(t, len(currencies) >= 35, \"Should have at least 35 currencies\")\n\n\t// Verify first and last entries match\n\tassert.Equal(t, \"USD\", currencies[0].Code)\n\tassert.Equal(t, \"RON\", currencies[len(currencies)-1].Code)\n}\n\nfunc TestSupportedCurrencies_DerivedFromBuiltin(t *testing.T) {\n\tassert.Equal(t, len(BuiltinCurrencies), len(SupportedCurrencies))\n\n\tfor i, info := range BuiltinCurrencies {\n\t\tassert.Equal(t, info.Code, SupportedCurrencies[i], \"SupportedCurrencies should match BuiltinCurrencies order\")\n\t}\n}\n\nfunc TestCurrencyInfoMap_AllEntriesPresent(t *testing.T) {\n\tfor _, info := range BuiltinCurrencies {\n\t\tmapped, ok := currencyInfoMap[info.Code]\n\t\tassert.True(t, ok, \"Currency %s should be in currencyInfoMap\", info.Code)\n\t\tassert.Equal(t, info, mapped)\n\t}\n}\n\nfunc TestCurrencySymbolForCode(t *testing.T) {\n\ttests := []struct {\n\t\tcode     string\n\t\texpected string\n\t}{\n\t\t{\"USD\", \"$\"},\n\t\t{\"EUR\", \"€\"},\n\t\t{\"GBP\", \"£\"},\n\t\t{\"COP\", \"COL$\"},\n\t\t{\"UNKNOWN\", \"UNKNOWN\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.code, func(t *testing.T) {\n\t\t\tassert.Equal(t, tt.expected, CurrencySymbolForCode(tt.code))\n\t\t})\n\t}\n}\n\nfunc TestCurrencySymbolForSubscription(t *testing.T) {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\terr = db.AutoMigrate(&models.Settings{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tsettingsService.SetCurrency(\"USD\")\n\n\ttests := []struct {\n\t\tname             string\n\t\toriginalCurrency string\n\t\texpectedSymbol   string\n\t}{\n\t\t{\n\t\t\tname:             \"Same as preferred currency\",\n\t\t\toriginalCurrency: \"USD\",\n\t\t\texpectedSymbol:   \"$\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Different from preferred currency\",\n\t\t\toriginalCurrency: \"EUR\",\n\t\t\texpectedSymbol:   \"€\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Empty original currency uses preferred\",\n\t\t\toriginalCurrency: \"\",\n\t\t\texpectedSymbol:   \"$\",\n\t\t},\n\t\t{\n\t\t\tname:             \"COP shows COL$ symbol\",\n\t\t\toriginalCurrency: \"COP\",\n\t\t\texpectedSymbol:   \"COL$\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsub := &models.Subscription{\n\t\t\t\tName:             \"Test\",\n\t\t\t\tOriginalCurrency: tt.originalCurrency,\n\t\t\t}\n\t\t\tsymbol := currencySymbolForSubscription(sub, settingsService)\n\t\t\tassert.Equal(t, tt.expectedSymbol, symbol)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/service/email.go",
    "content": "package service\n\nimport (\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/smtp\"\n\t\"subtrackr/internal/models\"\n)\n\n// currencySymbolForSubscription returns the appropriate currency symbol for a subscription.\n// If the subscription has an original currency that differs from the preferred currency,\n// use the subscription's own currency symbol to avoid misleading display.\nfunc currencySymbolForSubscription(subscription *models.Subscription, settings *SettingsService) string {\n\tpreferred := settings.GetCurrency()\n\tif subscription.OriginalCurrency != \"\" && subscription.OriginalCurrency != preferred {\n\t\treturn CurrencySymbolForCode(subscription.OriginalCurrency)\n\t}\n\treturn settings.GetCurrencySymbol()\n}\n\n// EmailService handles sending emails via SMTP\ntype EmailService struct {\n\tsettingsService *SettingsService\n}\n\n// NewEmailService creates a new email service\nfunc NewEmailService(settingsService *SettingsService) *EmailService {\n\treturn &EmailService{\n\t\tsettingsService: settingsService,\n\t}\n}\n\n// SendEmail sends an email using the configured SMTP settings\nfunc (e *EmailService) SendEmail(subject, body string) error {\n\tconfig, err := e.settingsService.GetSMTPConfig()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get SMTP config: %w\", err)\n\t}\n\n\tif config.To == \"\" {\n\t\treturn fmt.Errorf(\"no recipient email configured\")\n\t}\n\n\t// Determine if this is an implicit TLS port (SMTPS)\n\tisSSLPort := config.Port == 465 || config.Port == 8465 || config.Port == 443\n\n\tvar auth smtp.Auth\n\tvar addr string\n\n\tauth = smtp.PlainAuth(\"\", config.Username, config.Password, config.Host)\n\taddr = fmt.Sprintf(\"%s:%d\", config.Host, config.Port)\n\n\tif isSSLPort {\n\t\t// Use implicit TLS (direct SSL connection)\n\t\ttlsConfig := &tls.Config{\n\t\t\tServerName: config.Host,\n\t\t}\n\n\t\tconn, err := tls.Dial(\"tcp\", addr, tlsConfig)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to connect via SSL: %w\", err)\n\t\t}\n\t\tdefer conn.Close()\n\n\t\tclient, err := smtp.NewClient(conn, config.Host)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create SMTP client: %w\", err)\n\t\t}\n\t\tdefer client.Close()\n\n\t\t// Authenticate\n\t\tif err = client.Auth(auth); err != nil {\n\t\t\treturn fmt.Errorf(\"authentication failed: %w\", err)\n\t\t}\n\n\t\t// Set sender and recipient\n\t\tif err = client.Mail(config.From); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to set sender: %w\", err)\n\t\t}\n\t\tif err = client.Rcpt(config.To); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to set recipient: %w\", err)\n\t\t}\n\n\t\t// Send email body\n\t\twriter, err := client.Data()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get data writer: %w\", err)\n\t\t}\n\n\t\tfromName := config.FromName\n\t\tif fromName == \"\" {\n\t\t\tfromName = \"SubTrackr\"\n\t\t}\n\n\t\tmessage := fmt.Sprintf(\"From: %s <%s>\\r\\n\", fromName, config.From)\n\t\tmessage += fmt.Sprintf(\"To: %s\\r\\n\", config.To)\n\t\tmessage += fmt.Sprintf(\"Subject: %s\\r\\n\", subject)\n\t\tmessage += \"MIME-Version: 1.0\\r\\n\"\n\t\tmessage += \"Content-Type: text/html; charset=UTF-8\\r\\n\"\n\t\tmessage += \"\\r\\n\"\n\t\tmessage += body\n\n\t\t_, err = writer.Write([]byte(message))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to write message: %w\", err)\n\t\t}\n\t\terr = writer.Close()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to close writer: %w\", err)\n\t\t}\n\t} else {\n\t\t// Use STARTTLS (opportunistic TLS)\n\t\tclient, err := smtp.Dial(addr)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to connect: %w\", err)\n\t\t}\n\t\tdefer client.Close()\n\n\t\t// Upgrade to TLS\n\t\ttlsConfig := &tls.Config{\n\t\t\tServerName: config.Host,\n\t\t}\n\n\t\tif err = client.StartTLS(tlsConfig); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start TLS: %w\", err)\n\t\t}\n\n\t\t// Authenticate\n\t\tif err = client.Auth(auth); err != nil {\n\t\t\treturn fmt.Errorf(\"authentication failed: %w\", err)\n\t\t}\n\n\t\t// Set sender and recipient\n\t\tif err = client.Mail(config.From); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to set sender: %w\", err)\n\t\t}\n\t\tif err = client.Rcpt(config.To); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to set recipient: %w\", err)\n\t\t}\n\n\t\t// Send email body\n\t\twriter, err := client.Data()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get data writer: %w\", err)\n\t\t}\n\n\t\tfromName := config.FromName\n\t\tif fromName == \"\" {\n\t\t\tfromName = \"SubTrackr\"\n\t\t}\n\n\t\tmessage := fmt.Sprintf(\"From: %s <%s>\\r\\n\", fromName, config.From)\n\t\tmessage += fmt.Sprintf(\"To: %s\\r\\n\", config.To)\n\t\tmessage += fmt.Sprintf(\"Subject: %s\\r\\n\", subject)\n\t\tmessage += \"MIME-Version: 1.0\\r\\n\"\n\t\tmessage += \"Content-Type: text/html; charset=UTF-8\\r\\n\"\n\t\tmessage += \"\\r\\n\"\n\t\tmessage += body\n\n\t\t_, err = writer.Write([]byte(message))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to write message: %w\", err)\n\t\t}\n\t\terr = writer.Close()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to close writer: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// SendHighCostAlert sends an email alert when a high-cost subscription is created\nfunc (e *EmailService) SendHighCostAlert(subscription *models.Subscription) error {\n\t// Check if high cost alerts are enabled\n\tenabled, err := e.settingsService.GetBoolSetting(\"high_cost_alerts\", true)\n\tif err != nil || !enabled {\n\t\treturn nil // Silently skip if disabled\n\t}\n\n\t// Get currency symbol - use subscription's own currency if it differs from preferred\n\tcurrencySymbol := currencySymbolForSubscription(subscription, e.settingsService)\n\n\t// Build email body\n\ttmpl := `\n<!DOCTYPE html>\n<html>\n<head>\n\t<meta charset=\"UTF-8\">\n\t<style>\n\t\tbody { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }\n\t\t.container { max-width: 600px; margin: 0 auto; padding: 20px; }\n\t\t.alert { background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 5px; padding: 15px; margin: 20px 0; }\n\t\t.subscription-details { background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; }\n\t\t.detail-row { margin: 10px 0; }\n\t\t.label { font-weight: bold; }\n\t\t.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; font-size: 12px; color: #666; }\n\t</style>\n</head>\n<body>\n\t<div class=\"container\">\n\t\t<h2>High Cost Subscription Alert</h2>\n\t\t<div class=\"alert\">\n\t\t\t<strong>⚠️ Alert:</strong> A new high-cost subscription has been added to your SubTrackr account.\n\t\t</div>\n\t\t<div class=\"subscription-details\">\n\t\t\t<h3>Subscription Details</h3>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Name:</span> {{.Subscription.Name}}</div>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Cost:</span> {{.CurrencySymbol}}{{printf \"%.2f\" .Subscription.Cost}} {{.Subscription.DisplaySchedule}}</div>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Monthly Cost:</span> {{.CurrencySymbol}}{{printf \"%.2f\" (.Subscription.MonthlyCost)}}</div>\n\t\t\t{{if and .Subscription.Category .Subscription.Category.Name}}<div class=\"detail-row\"><span class=\"label\">Category:</span> {{.Subscription.Category.Name}}</div>{{end}}\n\t\t\t{{if .FormattedRenewalDate}}<div class=\"detail-row\"><span class=\"label\">Next Renewal:</span> {{.FormattedRenewalDate}}</div>{{end}}\n\t\t\t{{if .Subscription.URL}}<div class=\"detail-row\"><span class=\"label\">URL:</span> <a href=\"{{.Subscription.URL}}\">{{.Subscription.URL}}</a></div>{{end}}\n\t\t</div>\n\t\t<div class=\"footer\">\n\t\t\t<p>This is an automated notification from SubTrackr.</p>\n\t\t\t<p>You can manage your notification preferences in the Settings page.</p>\n\t\t</div>\n\t</div>\n</body>\n</html>\n`\n\n\ttype AlertData struct {\n\t\tSubscription        *models.Subscription\n\t\tCurrencySymbol      string\n\t\tFormattedRenewalDate string\n\t}\n\n\tvar formattedRenewal string\n\tif subscription.RenewalDate != nil {\n\t\tformattedRenewal = subscription.RenewalDate.Format(e.settingsService.GetGoDateFormatLong())\n\t}\n\n\tdata := AlertData{\n\t\tSubscription:        subscription,\n\t\tCurrencySymbol:      currencySymbol,\n\t\tFormattedRenewalDate: formattedRenewal,\n\t}\n\n\tt, err := template.New(\"highCostAlert\").Parse(tmpl)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse email template: %w\", err)\n\t}\n\n\tvar buf bytes.Buffer\n\tif err := t.Execute(&buf, data); err != nil {\n\t\treturn fmt.Errorf(\"failed to execute email template: %w\", err)\n\t}\n\n\tsubject := fmt.Sprintf(\"High Cost Alert: %s - %s%.2f/month\", subscription.Name, currencySymbol, subscription.MonthlyCost())\n\treturn e.SendEmail(subject, buf.String())\n}\n\n// SendRenewalReminder sends an email reminder for an upcoming subscription renewal\nfunc (e *EmailService) SendRenewalReminder(subscription *models.Subscription, daysUntilRenewal int) error {\n\t// Check if renewal reminders are enabled\n\tenabled, err := e.settingsService.GetBoolSetting(\"renewal_reminders\", false)\n\tif err != nil || !enabled {\n\t\treturn nil // Silently skip if disabled\n\t}\n\n\t// Get currency symbol - use subscription's own currency if it differs from preferred\n\tcurrencySymbol := currencySymbolForSubscription(subscription, e.settingsService)\n\n\t// Build email body\n\ttmpl := `\n<!DOCTYPE html>\n<html>\n<head>\n\t<meta charset=\"UTF-8\">\n\t<style>\n\t\tbody { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }\n\t\t.container { max-width: 600px; margin: 0 auto; padding: 20px; }\n\t\t.reminder { background-color: #d1ecf1; border: 1px solid #0c5460; border-radius: 5px; padding: 15px; margin: 20px 0; }\n\t\t.subscription-details { background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; }\n\t\t.detail-row { margin: 10px 0; }\n\t\t.label { font-weight: bold; }\n\t\t.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; font-size: 12px; color: #666; }\n\t</style>\n</head>\n<body>\n\t<div class=\"container\">\n\t\t<h2>Subscription Renewal Reminder</h2>\n\t\t<div class=\"reminder\">\n\t\t\t<strong>🔔 Reminder:</strong> Your subscription <strong>{{.Subscription.Name}}</strong> will renew in {{.DaysUntilRenewal}} {{if eq .DaysUntilRenewal 1}}day{{else}}days{{end}}.\n\t\t</div>\n\t\t<div class=\"subscription-details\">\n\t\t\t<h3>Subscription Details</h3>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Name:</span> {{.Subscription.Name}}</div>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Cost:</span> {{.CurrencySymbol}}{{printf \"%.2f\" .Subscription.Cost}} {{.Subscription.DisplaySchedule}}</div>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Monthly Cost:</span> {{.CurrencySymbol}}{{printf \"%.2f\" (.Subscription.MonthlyCost)}}</div>\n\t\t\t{{if and .Subscription.Category .Subscription.Category.Name}}<div class=\"detail-row\"><span class=\"label\">Category:</span> {{.Subscription.Category.Name}}</div>{{end}}\n\t\t\t{{if .FormattedRenewalDate}}<div class=\"detail-row\"><span class=\"label\">Renewal Date:</span> {{.FormattedRenewalDate}}</div>{{end}}\n\t\t\t{{if .Subscription.URL}}<div class=\"detail-row\"><span class=\"label\">URL:</span> <a href=\"{{.Subscription.URL}}\">{{.Subscription.URL}}</a></div>{{end}}\n\t\t</div>\n\t\t<div class=\"footer\">\n\t\t\t<p>This is an automated reminder from SubTrackr.</p>\n\t\t\t<p>You can manage your notification preferences in the Settings page.</p>\n\t\t</div>\n\t</div>\n</body>\n</html>\n`\n\n\ttype ReminderData struct {\n\t\tSubscription         *models.Subscription\n\t\tDaysUntilRenewal     int\n\t\tCurrencySymbol       string\n\t\tFormattedRenewalDate string\n\t}\n\n\tvar formattedRenewal string\n\tif subscription.RenewalDate != nil {\n\t\tformattedRenewal = subscription.RenewalDate.Format(e.settingsService.GetGoDateFormatLong())\n\t}\n\n\tdata := ReminderData{\n\t\tSubscription:         subscription,\n\t\tDaysUntilRenewal:     daysUntilRenewal,\n\t\tCurrencySymbol:       currencySymbol,\n\t\tFormattedRenewalDate: formattedRenewal,\n\t}\n\n\tt, err := template.New(\"renewalReminder\").Parse(tmpl)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse email template: %w\", err)\n\t}\n\n\tvar buf bytes.Buffer\n\tif err := t.Execute(&buf, data); err != nil {\n\t\treturn fmt.Errorf(\"failed to execute email template: %w\", err)\n\t}\n\n\tdaysText := \"days\"\n\tif daysUntilRenewal == 1 {\n\t\tdaysText = \"day\"\n\t}\n\tsubject := fmt.Sprintf(\"Renewal Reminder: %s renews in %d %s\", subscription.Name, daysUntilRenewal, daysText)\n\treturn e.SendEmail(subject, buf.String())\n}\n\n// SendCancellationReminder sends an email reminder for an upcoming subscription cancellation\nfunc (e *EmailService) SendCancellationReminder(subscription *models.Subscription, daysUntilCancellation int) error {\n\t// Check if cancellation reminders are enabled\n\tenabled, err := e.settingsService.GetBoolSetting(\"cancellation_reminders\", false)\n\tif err != nil || !enabled {\n\t\treturn nil // Silently skip if disabled\n\t}\n\n\t// Get currency symbol - use subscription's own currency if it differs from preferred\n\tcurrencySymbol := currencySymbolForSubscription(subscription, e.settingsService)\n\n\t// Build email body\n\ttmpl := `\n<!DOCTYPE html>\n<html>\n<head>\n\t<meta charset=\"UTF-8\">\n\t<style>\n\t\tbody { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }\n\t\t.container { max-width: 600px; margin: 0 auto; padding: 20px; }\n\t\t.reminder { background-color: #fff3cd; border: 1px solid #856404; border-radius: 5px; padding: 15px; margin: 20px 0; }\n\t\t.subscription-details { background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; }\n\t\t.detail-row { margin: 10px 0; }\n\t\t.label { font-weight: bold; }\n\t\t.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; font-size: 12px; color: #666; }\n\t</style>\n</head>\n<body>\n\t<div class=\"container\">\n\t\t<h2>Subscription Cancellation Reminder</h2>\n\t\t<div class=\"reminder\">\n\t\t\t<strong>⚠️ Reminder:</strong> Your subscription <strong>{{.Subscription.Name}}</strong> will end in {{.DaysUntilCancellation}} {{if eq .DaysUntilCancellation 1}}day{{else}}days{{end}}.\n\t\t</div>\n\t\t<div class=\"subscription-details\">\n\t\t\t<h3>Subscription Details</h3>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Name:</span> {{.Subscription.Name}}</div>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Cost:</span> {{.CurrencySymbol}}{{printf \"%.2f\" .Subscription.Cost}} {{.Subscription.DisplaySchedule}}</div>\n\t\t\t<div class=\"detail-row\"><span class=\"label\">Monthly Cost:</span> {{.CurrencySymbol}}{{printf \"%.2f\" (.Subscription.MonthlyCost)}}</div>\n\t\t\t{{if and .Subscription.Category .Subscription.Category.Name}}<div class=\"detail-row\"><span class=\"label\">Category:</span> {{.Subscription.Category.Name}}</div>{{end}}\n\t\t\t{{if .FormattedCancellationDate}}<div class=\"detail-row\"><span class=\"label\">Cancellation Date:</span> {{.FormattedCancellationDate}}</div>{{end}}\n\t\t\t{{if .Subscription.URL}}<div class=\"detail-row\"><span class=\"label\">URL:</span> <a href=\"{{.Subscription.URL}}\">{{.Subscription.URL}}</a></div>{{end}}\n\t\t</div>\n\t\t<div class=\"footer\">\n\t\t\t<p>This is an automated reminder from SubTrackr.</p>\n\t\t\t<p>You can manage your notification preferences in the Settings page.</p>\n\t\t</div>\n\t</div>\n</body>\n</html>\n`\n\n\ttype CancellationReminderData struct {\n\t\tSubscription               *models.Subscription\n\t\tDaysUntilCancellation      int\n\t\tCurrencySymbol             string\n\t\tFormattedCancellationDate  string\n\t}\n\n\tvar formattedCancellation string\n\tif subscription.CancellationDate != nil {\n\t\tformattedCancellation = subscription.CancellationDate.Format(e.settingsService.GetGoDateFormatLong())\n\t}\n\n\tdata := CancellationReminderData{\n\t\tSubscription:              subscription,\n\t\tDaysUntilCancellation:     daysUntilCancellation,\n\t\tCurrencySymbol:            currencySymbol,\n\t\tFormattedCancellationDate: formattedCancellation,\n\t}\n\n\tt, err := template.New(\"cancellationReminder\").Parse(tmpl)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse email template: %w\", err)\n\t}\n\n\tvar buf bytes.Buffer\n\tif err := t.Execute(&buf, data); err != nil {\n\t\treturn fmt.Errorf(\"failed to execute email template: %w\", err)\n\t}\n\n\tdaysText := \"days\"\n\tif daysUntilCancellation == 1 {\n\t\tdaysText = \"day\"\n\t}\n\tsubject := fmt.Sprintf(\"Cancellation Reminder: %s ends in %d %s\", subscription.Name, daysUntilCancellation, daysText)\n\treturn e.SendEmail(subject, buf.String())\n}\n"
  },
  {
    "path": "internal/service/logo.go",
    "content": "package service\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n)\n\n// LogoService handles fetching logos/icons for subscriptions\ntype LogoService struct {\n\thttpClient *http.Client\n}\n\n// NewLogoService creates a new logo service\nfunc NewLogoService() *LogoService {\n\treturn &LogoService{\n\t\thttpClient: &http.Client{\n\t\t\tTimeout: 10 * time.Second,\n\t\t},\n\t}\n}\n\n// FetchLogoFromURL extracts the domain from a website URL and returns a favicon URL\n// Uses Google's favicon service as the primary source\nfunc (s *LogoService) FetchLogoFromURL(websiteURL string) (string, error) {\n\tif websiteURL == \"\" {\n\t\treturn \"\", fmt.Errorf(\"empty URL provided\")\n\t}\n\n\t// Normalize URL - add https:// if no protocol is specified\n\tnormalizedURL := strings.TrimSpace(websiteURL)\n\tif !strings.HasPrefix(normalizedURL, \"http://\") && !strings.HasPrefix(normalizedURL, \"https://\") {\n\t\tnormalizedURL = \"https://\" + normalizedURL\n\t}\n\n\t// Parse the URL to extract domain\n\tparsedURL, err := url.Parse(normalizedURL)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid URL: %w\", err)\n\t}\n\n\t// Get the domain (hostname without port)\n\tdomain := parsedURL.Hostname()\n\tif domain == \"\" {\n\t\t// If hostname is empty, try using the path as domain (for cases like \"netflix.com\")\n\t\tif parsedURL.Path != \"\" {\n\t\t\tdomain = strings.TrimPrefix(parsedURL.Path, \"/\")\n\t\t} else {\n\t\t\treturn \"\", fmt.Errorf(\"could not extract domain from URL\")\n\t\t}\n\t}\n\n\t// Remove www. prefix for cleaner lookups\n\tdomain = strings.TrimPrefix(domain, \"www.\")\n\t// Remove trailing slashes\n\tdomain = strings.TrimSuffix(domain, \"/\")\n\n\t// Try Google's favicon service first (most reliable)\n\tfaviconURL := fmt.Sprintf(\"https://www.google.com/s2/favicons?domain=%s&sz=64\", url.QueryEscape(domain))\n\n\treturn faviconURL, nil\n}\n\n// GetLogoURL returns the logo URL for a subscription\n// Returns the stored IconURL if available, otherwise tries to fetch from URL\nfunc (s *LogoService) GetLogoURL(iconURL, websiteURL string) string {\n\t// If icon URL is already set, return it\n\tif iconURL != \"\" {\n\t\treturn iconURL\n\t}\n\n\t// If no website URL, return empty\n\tif websiteURL == \"\" {\n\t\treturn \"\"\n\t}\n\n\t// Try to fetch logo from website URL\n\tfetchedURL, err := s.FetchLogoFromURL(websiteURL)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn fetchedURL\n}\n\n// ValidateLogoURL checks if a logo URL is accessible\nfunc (s *LogoService) ValidateLogoURL(logoURL string) bool {\n\tif logoURL == \"\" {\n\t\treturn false\n\t}\n\n\tresp, err := s.httpClient.Head(logoURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check if response is successful (2xx) and is an image\n\treturn resp.StatusCode >= 200 && resp.StatusCode < 300\n}\n\n// FetchAndValidateLogo fetches a logo and validates it's accessible\nfunc (s *LogoService) FetchAndValidateLogo(websiteURL string) (string, error) {\n\tlogoURL, err := s.FetchLogoFromURL(websiteURL)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Validate the logo URL (check if it's accessible)\n\tif !s.ValidateLogoURL(logoURL) {\n\t\t// Still return the URL even if validation fails\n\t\t// The browser will handle broken images gracefully\n\t\treturn logoURL, nil\n\t}\n\n\treturn logoURL, nil\n}\n\n// ExtractDomain extracts the domain from a URL string\n// This is a helper method that reuses the domain extraction logic from FetchLogoFromURL\nfunc (s *LogoService) ExtractDomain(websiteURL string) string {\n\tif websiteURL == \"\" {\n\t\treturn \"\"\n\t}\n\n\t// Normalize URL - add https:// if no protocol is specified\n\tnormalizedURL := strings.TrimSpace(websiteURL)\n\tif !strings.HasPrefix(normalizedURL, \"http://\") && !strings.HasPrefix(normalizedURL, \"https://\") {\n\t\tnormalizedURL = \"https://\" + normalizedURL\n\t}\n\n\tparsedURL, err := url.Parse(normalizedURL)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\tdomain := parsedURL.Hostname()\n\tif domain == \"\" {\n\t\t// If hostname is empty, try using the path as domain\n\t\tif parsedURL.Path != \"\" {\n\t\t\tdomain = strings.TrimPrefix(parsedURL.Path, \"/\")\n\t\t} else {\n\t\t\treturn \"\"\n\t\t}\n\t}\n\n\tdomain = strings.TrimPrefix(domain, \"www.\")\n\tdomain = strings.TrimSuffix(domain, \"/\")\n\treturn domain\n}\n\n// DownloadLogo downloads a logo from a URL and returns the image data\n// This is for future use if we want to store logos locally\nfunc (s *LogoService) DownloadLogo(logoURL string) ([]byte, error) {\n\tresp, err := s.httpClient.Get(logoURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to download logo: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"failed to download logo: status %d\", resp.StatusCode)\n\t}\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read logo data: %w\", err)\n\t}\n\n\treturn data, nil\n}\n\n"
  },
  {
    "path": "internal/service/pushover.go",
    "content": "package service\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"subtrackr/internal/models\"\n\t\"time\"\n)\n\n// PushoverService handles sending notifications via Pushover\ntype PushoverService struct {\n\tsettingsService *SettingsService\n}\n\n// NewPushoverService creates a new Pushover service\nfunc NewPushoverService(settingsService *SettingsService) *PushoverService {\n\treturn &PushoverService{\n\t\tsettingsService: settingsService,\n\t}\n}\n\n// PushoverResponse represents the response from Pushover API\ntype PushoverResponse struct {\n\tStatus  int      `json:\"status\"`\n\tRequest string   `json:\"request\"`\n\tErrors  []string `json:\"errors,omitempty\"`\n}\n\n// SendNotification sends a notification via Pushover\nfunc (p *PushoverService) SendNotification(title, message string, priority int) error {\n\tconfig, err := p.settingsService.GetPushoverConfig()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get Pushover config: %w\", err)\n\t}\n\n\tif config.UserKey == \"\" || config.AppToken == \"\" {\n\t\treturn fmt.Errorf(\"Pushover not configured: user key and app token required\")\n\t}\n\n\t// Pushover API endpoint\n\tapiURL := \"https://api.pushover.net/1/messages.json\"\n\n\t// Prepare form data\n\tformData := url.Values{}\n\tformData.Set(\"token\", config.AppToken)\n\tformData.Set(\"user\", config.UserKey)\n\tformData.Set(\"title\", title)\n\tformData.Set(\"message\", message)\n\tformData.Set(\"priority\", strconv.Itoa(priority))\n\n\t// Create HTTP request\n\treq, err := http.NewRequest(\"POST\", apiURL, bytes.NewBufferString(formData.Encode()))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\n\t// Send request\n\tclient := &http.Client{\n\t\tTimeout: 10 * time.Second,\n\t}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send Pushover notification: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Parse response\n\tvar pushoverResp PushoverResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&pushoverResp); err != nil {\n\t\treturn fmt.Errorf(\"failed to decode Pushover response: %w\", err)\n\t}\n\n\tif pushoverResp.Status != 1 {\n\t\terrorMsg := \"Pushover API error\"\n\t\tif len(pushoverResp.Errors) > 0 {\n\t\t\terrorMsg = pushoverResp.Errors[0]\n\t\t}\n\t\treturn fmt.Errorf(\"%s\", errorMsg)\n\t}\n\n\treturn nil\n}\n\n// SendHighCostAlert sends a Pushover alert when a high-cost subscription is created\nfunc (p *PushoverService) SendHighCostAlert(subscription *models.Subscription) error {\n\t// Check if high cost alerts are enabled\n\tenabled, err := p.settingsService.GetBoolSetting(\"high_cost_alerts\", true)\n\tif err != nil || !enabled {\n\t\treturn nil // Silently skip if disabled\n\t}\n\n\t// Get currency symbol - use subscription's own currency if it differs from preferred\n\tcurrencySymbol := currencySymbolForSubscription(subscription, p.settingsService)\n\n\t// Build message\n\tmessage := \"⚠️ High Cost Alert\\n\\n\"\n\tmessage += fmt.Sprintf(\"Subscription: %s\\n\", subscription.Name)\n\tmessage += fmt.Sprintf(\"Cost: %s%.2f %s\\n\", currencySymbol, subscription.Cost, subscription.DisplaySchedule())\n\tmessage += fmt.Sprintf(\"Monthly Cost: %s%.2f\\n\", currencySymbol, subscription.MonthlyCost())\n\tif subscription.Category.Name != \"\" {\n\t\tmessage += fmt.Sprintf(\"Category: %s\\n\", subscription.Category.Name)\n\t}\n\tif subscription.RenewalDate != nil {\n\t\tmessage += fmt.Sprintf(\"Next Renewal: %s\\n\", subscription.RenewalDate.Format(p.settingsService.GetGoDateFormatLong()))\n\t}\n\tif subscription.URL != \"\" {\n\t\tmessage += fmt.Sprintf(\"URL: %s\", subscription.URL)\n\t}\n\n\ttitle := fmt.Sprintf(\"High Cost Alert: %s\", subscription.Name)\n\t// Priority 1 = high priority (with sound and vibration)\n\treturn p.SendNotification(title, message, 1)\n}\n\n// SendRenewalReminder sends a Pushover reminder for an upcoming subscription renewal\nfunc (p *PushoverService) SendRenewalReminder(subscription *models.Subscription, daysUntilRenewal int) error {\n\t// Check if renewal reminders are enabled\n\tenabled, err := p.settingsService.GetBoolSetting(\"renewal_reminders\", false)\n\tif err != nil || !enabled {\n\t\treturn nil // Silently skip if disabled\n\t}\n\n\t// Get currency symbol - use subscription's own currency if it differs from preferred\n\tcurrencySymbol := currencySymbolForSubscription(subscription, p.settingsService)\n\n\t// Build message\n\tdaysText := \"days\"\n\tif daysUntilRenewal == 1 {\n\t\tdaysText = \"day\"\n\t}\n\tmessage := \"🔔 Renewal Reminder\\n\\n\"\n\tmessage += fmt.Sprintf(\"Your subscription %s will renew in %d %s.\\n\\n\", subscription.Name, daysUntilRenewal, daysText)\n\tmessage += \"Subscription Details:\\n\"\n\tmessage += fmt.Sprintf(\"Cost: %s%.2f %s\\n\", currencySymbol, subscription.Cost, subscription.DisplaySchedule())\n\tmessage += fmt.Sprintf(\"Monthly Cost: %s%.2f\\n\", currencySymbol, subscription.MonthlyCost())\n\tif subscription.Category.Name != \"\" {\n\t\tmessage += fmt.Sprintf(\"Category: %s\\n\", subscription.Category.Name)\n\t}\n\tif subscription.RenewalDate != nil {\n\t\tmessage += fmt.Sprintf(\"Renewal Date: %s\\n\", subscription.RenewalDate.Format(p.settingsService.GetGoDateFormatLong()))\n\t}\n\tif subscription.URL != \"\" {\n\t\tmessage += fmt.Sprintf(\"URL: %s\", subscription.URL)\n\t}\n\n\ttitle := fmt.Sprintf(\"Renewal Reminder: %s\", subscription.Name)\n\t// Priority 0 = normal priority\n\treturn p.SendNotification(title, message, 0)\n}\n\n// SendCancellationReminder sends a Pushover reminder for an upcoming subscription cancellation\nfunc (p *PushoverService) SendCancellationReminder(subscription *models.Subscription, daysUntilCancellation int) error {\n\t// Check if cancellation reminders are enabled\n\tenabled, err := p.settingsService.GetBoolSetting(\"cancellation_reminders\", false)\n\tif err != nil || !enabled {\n\t\treturn nil // Silently skip if disabled\n\t}\n\n\t// Get currency symbol - use subscription's own currency if it differs from preferred\n\tcurrencySymbol := currencySymbolForSubscription(subscription, p.settingsService)\n\n\t// Build message\n\tdaysText := \"days\"\n\tif daysUntilCancellation == 1 {\n\t\tdaysText = \"day\"\n\t}\n\tmessage := \"⚠️ Cancellation Reminder\\n\\n\"\n\tmessage += fmt.Sprintf(\"Your subscription %s will end in %d %s.\\n\\n\", subscription.Name, daysUntilCancellation, daysText)\n\tmessage += \"Subscription Details:\\n\"\n\tmessage += fmt.Sprintf(\"Cost: %s%.2f %s\\n\", currencySymbol, subscription.Cost, subscription.DisplaySchedule())\n\tmessage += fmt.Sprintf(\"Monthly Cost: %s%.2f\\n\", currencySymbol, subscription.MonthlyCost())\n\tif subscription.Category.Name != \"\" {\n\t\tmessage += fmt.Sprintf(\"Category: %s\\n\", subscription.Category.Name)\n\t}\n\tif subscription.CancellationDate != nil {\n\t\tmessage += fmt.Sprintf(\"Cancellation Date: %s\\n\", subscription.CancellationDate.Format(p.settingsService.GetGoDateFormatLong()))\n\t}\n\tif subscription.URL != \"\" {\n\t\tmessage += fmt.Sprintf(\"URL: %s\", subscription.URL)\n\t}\n\n\ttitle := fmt.Sprintf(\"Cancellation Reminder: %s\", subscription.Name)\n\t// Priority 0 = normal priority\n\treturn p.SendNotification(title, message, 0)\n}\n\n"
  },
  {
    "path": "internal/service/pushover_test.go",
    "content": "package service\n\nimport (\n\t\"os\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\n// Pushover Test Credentials Usage:\n//\n// For unit tests (default): Tests use mock credentials and will fail API calls (expected behavior)\n//\n// For integration tests: Set environment variables before running tests:\n//   export PUSHOVER_USER_KEY=\"your_user_key_here\"\n//   export PUSHOVER_APP_TOKEN=\"your_app_token_here\"\n//\n// Integration tests will automatically skip if credentials are not provided.\n// Example:\n//   PUSHOVER_USER_KEY=\"u1234567890abcdef\" PUSHOVER_APP_TOKEN=\"a1b2c3d4e5f6g7h8\" go test ./internal/service -run Integration\n\nfunc setupPushoverTestDB(t *testing.T) *gorm.DB {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\n\t// Migrate the schema\n\terr = db.AutoMigrate(\n\t\t&models.Settings{},\n\t\t&models.Category{},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\treturn db\n}\n\nfunc TestPushoverService_SendNotification_NoConfig(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Try to send notification without config\n\terr := pushoverService.SendNotification(\"Test\", \"Test message\", 0)\n\tassert.Error(t, err, \"Should return error when Pushover is not configured\")\n\t// Error will be \"failed to get Pushover config: record not found\" when no config exists\n\tassert.Contains(t, err.Error(), \"Pushover config\", \"Error should mention Pushover config\")\n}\n\nfunc TestPushoverService_SendNotification_EmptyUserKey(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Configure with empty user key (but valid app token)\n\tconfig := &models.PushoverConfig{\n\t\tUserKey:  \"\", // Empty user key\n\t\tAppToken: \"test-app-token\",\n\t}\n\tsettingsService.SavePushoverConfig(config)\n\n\terr := pushoverService.SendNotification(\"Test\", \"Test message\", 0)\n\tassert.Error(t, err, \"Should return error when User Key is empty\")\n\tassert.Contains(t, err.Error(), \"not configured\", \"Error should mention not configured\")\n}\n\nfunc TestPushoverService_SendNotification_EmptyAppToken(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Configure with empty app token\n\tconfig := &models.PushoverConfig{\n\t\tUserKey:  \"test-user-key\",\n\t\tAppToken: \"\",\n\t}\n\tsettingsService.SavePushoverConfig(config)\n\n\terr := pushoverService.SendNotification(\"Test\", \"Test message\", 0)\n\tassert.Error(t, err, \"Should return error when App Token is empty\")\n\tassert.Contains(t, err.Error(), \"not configured\", \"Error should mention not configured\")\n}\n\nfunc TestPushoverService_SendHighCostAlert_Disabled(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Ensure high cost alerts are disabled\n\tsettingsService.SetBoolSetting(\"high_cost_alerts\", false)\n\n\tsubscription := &models.Subscription{\n\t\tName:     \"Test Subscription\",\n\t\tCost:     100.00,\n\t\tSchedule: \"Monthly\",\n\t\tStatus:   \"Active\",\n\t\tCategory: models.Category{Name: \"Test\"},\n\t}\n\n\t// Should return nil without error when disabled\n\terr := pushoverService.SendHighCostAlert(subscription)\n\tassert.NoError(t, err, \"Should return nil when high cost alerts are disabled\")\n}\n\nfunc TestPushoverService_SendHighCostAlert_EnabledButNoConfig(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Enable high cost alerts but don't configure Pushover\n\tsettingsService.SetBoolSetting(\"high_cost_alerts\", true)\n\tsettingsService.SetCurrency(\"USD\")\n\n\tsubscription := &models.Subscription{\n\t\tName:     \"Test Subscription\",\n\t\tCost:     100.00,\n\t\tSchedule: \"Monthly\",\n\t\tStatus:   \"Active\",\n\t\tCategory: models.Category{Name: \"Test\"},\n\t}\n\n\t// Should return error when Pushover is not configured\n\terr := pushoverService.SendHighCostAlert(subscription)\n\tassert.Error(t, err, \"Should return error when Pushover is not configured\")\n}\n\nfunc TestPushoverService_SendRenewalReminder_Disabled(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Ensure renewal reminders are disabled\n\tsettingsService.SetBoolSetting(\"renewal_reminders\", false)\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t\tCategory:    models.Category{Name: \"Test\"},\n\t}\n\n\t// Should return nil without error when disabled\n\terr := pushoverService.SendRenewalReminder(subscription, 3)\n\tassert.NoError(t, err, \"Should return nil when renewal reminders are disabled\")\n}\n\nfunc TestPushoverService_SendRenewalReminder_EnabledButNoConfig(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Enable renewal reminders but don't configure Pushover\n\tsettingsService.SetBoolSetting(\"renewal_reminders\", true)\n\tsettingsService.SetCurrency(\"USD\")\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t\tCategory:    models.Category{Name: \"Test\"},\n\t}\n\n\t// Should return error when Pushover is not configured\n\terr := pushoverService.SendRenewalReminder(subscription, 3)\n\tassert.Error(t, err, \"Should return error when Pushover is not configured\")\n}\n\nfunc TestPushoverService_SendHighCostAlert_MessageFormat(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Configure Pushover with invalid credentials (we're testing message format, not actual sending)\n\tconfig := &models.PushoverConfig{\n\t\tUserKey:  \"test-user-key\",\n\t\tAppToken: \"test-app-token\",\n\t}\n\tsettingsService.SavePushoverConfig(config)\n\tsettingsService.SetBoolSetting(\"high_cost_alerts\", true)\n\tsettingsService.SetCurrency(\"USD\")\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Netflix\",\n\t\tCost:        15.99,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 30)),\n\t\tCategory:    models.Category{Name: \"Entertainment\"},\n\t\tURL:         \"https://netflix.com\",\n\t}\n\n\t// This will fail because we don't have real Pushover credentials, but it should attempt to send\n\terr := pushoverService.SendHighCostAlert(subscription)\n\t// We expect an error because we can't actually connect to Pushover API, but the function should attempt to send\n\tassert.Error(t, err, \"Should return error when Pushover API call fails (expected in test)\")\n\t// The error should be about API call, not about being disabled\n\tassert.NotContains(t, err.Error(), \"disabled\", \"Error should not be about being disabled\")\n}\n\nfunc TestPushoverService_SendRenewalReminder_MessageFormat(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Configure Pushover with invalid credentials (we're testing message format, not actual sending)\n\tconfig := &models.PushoverConfig{\n\t\tUserKey:  \"test-user-key\",\n\t\tAppToken: \"test-app-token\",\n\t}\n\tsettingsService.SavePushoverConfig(config)\n\tsettingsService.SetBoolSetting(\"renewal_reminders\", true)\n\tsettingsService.SetCurrency(\"USD\")\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Netflix\",\n\t\tCost:        15.99,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t\tCategory:    models.Category{Name: \"Entertainment\"},\n\t\tURL:         \"https://netflix.com\",\n\t}\n\n\t// This will fail because we don't have real Pushover credentials, but it should attempt to send\n\terr := pushoverService.SendRenewalReminder(subscription, 3)\n\t// We expect an error because we can't actually connect to Pushover API, but the function should attempt to send\n\tassert.Error(t, err, \"Should return error when Pushover API call fails (expected in test)\")\n\t// The error should be about API call, not about being disabled\n\tassert.NotContains(t, err.Error(), \"disabled\", \"Error should not be about being disabled\")\n}\n\nfunc TestPushoverService_SendRenewalReminder_DaysText(t *testing.T) {\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Configure Pushover\n\tconfig := &models.PushoverConfig{\n\t\tUserKey:  \"test-user-key\",\n\t\tAppToken: \"test-app-token\",\n\t}\n\tsettingsService.SavePushoverConfig(config)\n\tsettingsService.SetBoolSetting(\"renewal_reminders\", true)\n\tsettingsService.SetCurrency(\"USD\")\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 1)),\n\t\tCategory:    models.Category{Name: \"Test\"},\n\t}\n\n\ttests := []struct {\n\t\tname             string\n\t\tdaysUntil        int\n\t\texpectedDaysText string\n\t}{\n\t\t{\n\t\t\tname:             \"Singular day\",\n\t\t\tdaysUntil:        1,\n\t\t\texpectedDaysText: \"day\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Plural days\",\n\t\t\tdaysUntil:        3,\n\t\t\texpectedDaysText: \"days\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// This will fail because we don't have real Pushover credentials\n\t\t\terr := pushoverService.SendRenewalReminder(subscription, tt.daysUntil)\n\t\t\tassert.Error(t, err, \"Should return error when Pushover API call fails (expected in test)\")\n\t\t\t// Verify the function attempted to send (not disabled)\n\t\t\tassert.NotContains(t, err.Error(), \"disabled\", \"Error should not be about being disabled\")\n\t\t})\n\t}\n}\n\n// getPushoverTestCredentials retrieves Pushover credentials from environment variables for integration testing\n// Returns empty strings if not set (for unit tests)\nfunc getPushoverTestCredentials() (userKey, appToken string) {\n\tuserKey = os.Getenv(\"PUSHOVER_USER_KEY\")\n\tappToken = os.Getenv(\"PUSHOVER_APP_TOKEN\")\n\treturn userKey, appToken\n}\n\n// TestPushoverService_SendNotification_Integration tests sending a real notification if credentials are provided\n// This is an optional integration test that only runs if PUSHOVER_USER_KEY and PUSHOVER_APP_TOKEN are set\nfunc TestPushoverService_SendNotification_Integration(t *testing.T) {\n\tuserKey, appToken := getPushoverTestCredentials()\n\tif userKey == \"\" || appToken == \"\" {\n\t\tt.Skip(\"Skipping integration test: PUSHOVER_USER_KEY and PUSHOVER_APP_TOKEN environment variables not set\")\n\t}\n\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Configure Pushover with real credentials from environment\n\tconfig := &models.PushoverConfig{\n\t\tUserKey:  userKey,\n\t\tAppToken: appToken,\n\t}\n\terr := settingsService.SavePushoverConfig(config)\n\tassert.NoError(t, err, \"Should save Pushover config\")\n\n\t// Send a test notification\n\terr = pushoverService.SendNotification(\"SubTrackr Test\", \"This is a test notification from SubTrackr integration tests\", 0)\n\tassert.NoError(t, err, \"Should successfully send notification with valid credentials\")\n}\n\n// TestPushoverService_SendHighCostAlert_Integration tests sending a real high cost alert if credentials are provided\nfunc TestPushoverService_SendHighCostAlert_Integration(t *testing.T) {\n\tuserKey, appToken := getPushoverTestCredentials()\n\tif userKey == \"\" || appToken == \"\" {\n\t\tt.Skip(\"Skipping integration test: PUSHOVER_USER_KEY and PUSHOVER_APP_TOKEN environment variables not set\")\n\t}\n\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Configure Pushover with real credentials\n\tconfig := &models.PushoverConfig{\n\t\tUserKey:  userKey,\n\t\tAppToken: appToken,\n\t}\n\tsettingsService.SavePushoverConfig(config)\n\tsettingsService.SetBoolSetting(\"high_cost_alerts\", true)\n\tsettingsService.SetCurrency(\"USD\")\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Test High Cost Subscription\",\n\t\tCost:        100.00,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 30)),\n\t\tCategory:    models.Category{Name: \"Test\"},\n\t\tURL:         \"https://example.com\",\n\t}\n\n\terr := pushoverService.SendHighCostAlert(subscription)\n\tassert.NoError(t, err, \"Should successfully send high cost alert with valid credentials\")\n}\n\n// TestPushoverService_SendRenewalReminder_Integration tests sending a real renewal reminder if credentials are provided\nfunc TestPushoverService_SendRenewalReminder_Integration(t *testing.T) {\n\tuserKey, appToken := getPushoverTestCredentials()\n\tif userKey == \"\" || appToken == \"\" {\n\t\tt.Skip(\"Skipping integration test: PUSHOVER_USER_KEY and PUSHOVER_APP_TOKEN environment variables not set\")\n\t}\n\n\tdb := setupPushoverTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tpushoverService := NewPushoverService(settingsService)\n\n\t// Configure Pushover with real credentials\n\tconfig := &models.PushoverConfig{\n\t\tUserKey:  userKey,\n\t\tAppToken: appToken,\n\t}\n\tsettingsService.SavePushoverConfig(config)\n\tsettingsService.SetBoolSetting(\"renewal_reminders\", true)\n\tsettingsService.SetCurrency(\"USD\")\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        15.99,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t\tCategory:    models.Category{Name: \"Test\"},\n\t\tURL:         \"https://example.com\",\n\t}\n\n\terr := pushoverService.SendRenewalReminder(subscription, 3)\n\tassert.NoError(t, err, \"Should successfully send renewal reminder with valid credentials\")\n}\n"
  },
  {
    "path": "internal/service/renewal_reminder_test.go",
    "content": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc setupRenewalReminderTestDB(t *testing.T) *gorm.DB {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\n\t// Migrate the schema\n\terr = db.AutoMigrate(\n\t\t&models.Subscription{},\n\t\t&models.Category{},\n\t\t&models.Settings{},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\treturn db\n}\n\nfunc TestSubscriptionService_GetSubscriptionsNeedingReminders(t *testing.T) {\n\tdb := setupRenewalReminderTestDB(t)\n\tsubscriptionRepo := repository.NewSubscriptionRepository(db)\n\tcategoryRepo := repository.NewCategoryRepository(db)\n\tcategoryService := NewCategoryService(categoryRepo)\n\tsubscriptionService := NewSubscriptionService(subscriptionRepo, categoryService)\n\n\tnow := time.Now()\n\n\ttests := []struct {\n\t\tname          string\n\t\treminderDays  int\n\t\tsubscriptions []models.Subscription\n\t\texpectedCount int\n\t\tdescription   string\n\t}{\n\t\t{\n\t\t\tname:         \"Subscription renewing in 3 days with 7 day reminder\",\n\t\t\treminderDays: 7,\n\t\t\tsubscriptions: []models.Subscription{\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 1\",\n\t\t\t\t\tCost:        10.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: timePtr(now.AddDate(0, 0, 3)), // 3 days from now\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount: 1,\n\t\t\tdescription:   \"Should find subscription renewing within reminder window\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Subscription renewing in 10 days with 7 day reminder\",\n\t\t\treminderDays: 7,\n\t\t\tsubscriptions: []models.Subscription{\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 2\",\n\t\t\t\t\tCost:        10.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: timePtr(now.AddDate(0, 0, 10)), // 10 days from now\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\tdescription:   \"Should not find subscription outside reminder window\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Subscription renewing today\",\n\t\t\treminderDays: 7,\n\t\t\tsubscriptions: []models.Subscription{\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 3\",\n\t\t\t\t\tCost:        10.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: timePtr(now.Add(12 * time.Hour)), // 12 hours from now\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount: 1,\n\t\t\tdescription:   \"Should find subscription renewing today (within 24 hours)\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Multiple subscriptions in reminder window\",\n\t\t\treminderDays: 7,\n\t\t\tsubscriptions: []models.Subscription{\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 4\",\n\t\t\t\t\tCost:        10.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: timePtr(now.AddDate(0, 0, 2)), // 2 days\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 5\",\n\t\t\t\t\tCost:        20.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: timePtr(now.AddDate(0, 0, 5)), // 5 days\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 6\",\n\t\t\t\t\tCost:        30.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: timePtr(now.AddDate(0, 0, 10)), // 10 days (outside window)\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount: 2,\n\t\t\tdescription:   \"Should find only subscriptions within reminder window\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Cancelled subscription should be excluded\",\n\t\t\treminderDays: 7,\n\t\t\tsubscriptions: []models.Subscription{\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 7\",\n\t\t\t\t\tCost:        10.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Cancelled\",\n\t\t\t\t\tRenewalDate: timePtr(now.AddDate(0, 0, 3)), // 3 days\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\tdescription:   \"Should exclude cancelled subscriptions\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Subscription without renewal date should be excluded\",\n\t\t\treminderDays: 7,\n\t\t\tsubscriptions: []models.Subscription{\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 8\",\n\t\t\t\t\tCost:        10.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: nil,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\tdescription:   \"Should exclude subscriptions without renewal date\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Zero reminder days should return empty\",\n\t\t\treminderDays: 0,\n\t\t\tsubscriptions: []models.Subscription{\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 9\",\n\t\t\t\t\tCost:        10.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: timePtr(now.AddDate(0, 0, 3)),\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\tdescription:   \"Should return empty when reminder days is 0\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Past renewal date should be excluded\",\n\t\t\treminderDays: 7,\n\t\t\tsubscriptions: []models.Subscription{\n\t\t\t\t{\n\t\t\t\t\tName:        \"Test Subscription 10\",\n\t\t\t\t\tCost:        10.00,\n\t\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\t\tStatus:      \"Active\",\n\t\t\t\t\tRenewalDate: timePtr(now.AddDate(0, 0, -1)), // 1 day ago\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount: 0,\n\t\t\tdescription:   \"Should exclude subscriptions with past renewal dates\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Clean up previous test data\n\t\t\tdb.Exec(\"DELETE FROM subscriptions\")\n\n\t\t\t// Create test subscriptions\n\t\t\tfor _, sub := range tt.subscriptions {\n\t\t\t\terr := db.Create(&sub).Error\n\t\t\t\tassert.NoError(t, err, \"Failed to create test subscription\")\n\t\t\t}\n\n\t\t\t// Get subscriptions needing reminders\n\t\t\tresult, err := subscriptionService.GetSubscriptionsNeedingReminders(tt.reminderDays)\n\t\t\tassert.NoError(t, err, \"GetSubscriptionsNeedingReminders should not return error\")\n\t\t\tassert.Equal(t, tt.expectedCount, len(result), tt.description)\n\n\t\t\t// Verify days until renewal calculation\n\t\t\tfor sub, daysUntil := range result {\n\t\t\t\tassert.GreaterOrEqual(t, daysUntil, 0, \"Days until renewal should be non-negative\")\n\t\t\t\tassert.LessOrEqual(t, daysUntil, tt.reminderDays, \"Days until renewal should be within reminder window\")\n\t\t\t\tassert.Equal(t, \"Active\", sub.Status, \"Subscription should be active\")\n\t\t\t\tassert.NotNil(t, sub.RenewalDate, \"Subscription should have renewal date\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmailService_SendRenewalReminder_Disabled(t *testing.T) {\n\tdb := setupRenewalReminderTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\temailService := NewEmailService(settingsService)\n\n\t// Ensure reminders are disabled\n\tsettingsService.SetBoolSetting(\"renewal_reminders\", false)\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t}\n\n\t// Should return nil without error when disabled\n\terr := emailService.SendRenewalReminder(subscription, 3)\n\tassert.NoError(t, err, \"Should return nil when reminders are disabled\")\n}\n\nfunc TestEmailService_SendRenewalReminder_EnabledButNoSMTP(t *testing.T) {\n\tdb := setupRenewalReminderTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\temailService := NewEmailService(settingsService)\n\n\t// Enable reminders but don't configure SMTP\n\tsettingsService.SetBoolSetting(\"renewal_reminders\", true)\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t}\n\n\t// Should return error when SMTP is not configured\n\terr := emailService.SendRenewalReminder(subscription, 3)\n\tassert.Error(t, err, \"Should return error when SMTP is not configured\")\n\tassert.Contains(t, err.Error(), \"SMTP\", \"Error should mention SMTP\")\n}\n\nfunc TestEmailService_SendRenewalReminder_WithSMTPConfig(t *testing.T) {\n\tdb := setupRenewalReminderTestDB(t)\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\temailService := NewEmailService(settingsService)\n\n\t// Enable reminders\n\tsettingsService.SetBoolSetting(\"renewal_reminders\", true)\n\n\t// Configure SMTP (using invalid config - we're just testing the logic, not actual email sending)\n\tsmtpConfig := &models.SMTPConfig{\n\t\tHost:     \"smtp.example.com\",\n\t\tPort:     587,\n\t\tUsername: \"test@example.com\",\n\t\tPassword: \"password\",\n\t\tFrom:     \"test@example.com\",\n\t\tFromName: \"Test\",\n\t\tTo:       \"recipient@example.com\",\n\t}\n\tsettingsService.SaveSMTPConfig(smtpConfig)\n\n\tsubscription := &models.Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t}\n\n\t// This will fail because we don't have a real SMTP server, but it should get past the enabled check\n\terr := emailService.SendRenewalReminder(subscription, 3)\n\t// We expect an error because we can't actually connect to SMTP, but the function should attempt to send\n\tassert.Error(t, err, \"Should return error when SMTP connection fails (expected in test)\")\n\t// The error should be about connection, not about being disabled\n\tassert.NotContains(t, err.Error(), \"disabled\", \"Error should not be about being disabled\")\n}\n\nfunc TestSubscriptionService_GetSubscriptionsNeedingReminders_DaysCalculation(t *testing.T) {\n\tdb := setupRenewalReminderTestDB(t)\n\tsubscriptionRepo := repository.NewSubscriptionRepository(db)\n\tcategoryRepo := repository.NewCategoryRepository(db)\n\tcategoryService := NewCategoryService(categoryRepo)\n\tsubscriptionService := NewSubscriptionService(subscriptionRepo, categoryService)\n\n\tnow := time.Now()\n\n\t// Create subscription renewing in exactly 5 days\n\trenewalDate := now.AddDate(0, 0, 5)\n\tsub := &models.Subscription{\n\t\tName:        \"Test Subscription\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tStatus:      \"Active\",\n\t\tRenewalDate: &renewalDate,\n\t}\n\terr := db.Create(sub).Error\n\tassert.NoError(t, err)\n\n\t// Get subscriptions needing reminders with 7 day window\n\tresult, err := subscriptionService.GetSubscriptionsNeedingReminders(7)\n\tassert.NoError(t, err)\n\tassert.Equal(t, 1, len(result), \"Should find one subscription\")\n\n\t// Check days until renewal\n\tfor foundSub, daysUntil := range result {\n\t\tassert.Equal(t, sub.ID, foundSub.ID, \"Should be the same subscription\")\n\t\t// Days should be approximately 5 (allowing for small time differences)\n\t\tassert.InDelta(t, 5, daysUntil, 1, \"Days until renewal should be approximately 5\")\n\t}\n}\n\nfunc TestSubscriptionService_GetSubscriptionsNeedingReminders_BoundaryCases(t *testing.T) {\n\tdb := setupRenewalReminderTestDB(t)\n\tsubscriptionRepo := repository.NewSubscriptionRepository(db)\n\tcategoryRepo := repository.NewCategoryRepository(db)\n\tcategoryService := NewCategoryService(categoryRepo)\n\tsubscriptionService := NewSubscriptionService(subscriptionRepo, categoryService)\n\n\tnow := time.Now()\n\n\ttests := []struct {\n\t\tname         string\n\t\trenewalDate  time.Time\n\t\treminderDays int\n\t\tshouldFind   bool\n\t\tdescription  string\n\t}{\n\t\t{\n\t\t\tname:         \"Exactly at reminder window boundary\",\n\t\t\trenewalDate:  now.AddDate(0, 0, 7), // Exactly 7 days\n\t\t\treminderDays: 7,\n\t\t\tshouldFind:   true,\n\t\t\tdescription:  \"Should find subscription renewing exactly at reminder window boundary\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Just outside reminder window\",\n\t\t\trenewalDate:  now.AddDate(0, 0, 8), // 8 days (outside 7 day window)\n\t\t\treminderDays: 7,\n\t\t\tshouldFind:   false,\n\t\t\tdescription:  \"Should not find subscription just outside reminder window\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Renewing tomorrow\",\n\t\t\trenewalDate:  now.AddDate(0, 0, 1), // 1 day\n\t\t\treminderDays: 7,\n\t\t\tshouldFind:   true,\n\t\t\tdescription:  \"Should find subscription renewing tomorrow\",\n\t\t},\n\t\t{\n\t\t\tname:         \"Renewing in 1 hour (less than 1 day)\",\n\t\t\trenewalDate:  now.Add(1 * time.Hour),\n\t\t\treminderDays: 7,\n\t\t\tshouldFind:   true,\n\t\t\tdescription:  \"Should find subscription renewing in less than 1 day (counts as 0 days)\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Clean up\n\t\t\tdb.Exec(\"DELETE FROM subscriptions\")\n\n\t\t\tsub := &models.Subscription{\n\t\t\t\tName:        \"Test Subscription\",\n\t\t\t\tCost:        10.00,\n\t\t\t\tSchedule:    \"Monthly\",\n\t\t\t\tStatus:      \"Active\",\n\t\t\t\tRenewalDate: &tt.renewalDate,\n\t\t\t}\n\t\t\terr := db.Create(sub).Error\n\t\t\tassert.NoError(t, err)\n\n\t\t\tresult, err := subscriptionService.GetSubscriptionsNeedingReminders(tt.reminderDays)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tif tt.shouldFind {\n\t\t\t\tassert.Equal(t, 1, len(result), tt.description)\n\t\t\t} else {\n\t\t\t\tassert.Equal(t, 0, len(result), tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSubscriptionService_GetSubscriptionsNeedingReminders_DuplicatePrevention(t *testing.T) {\n\tdb := setupRenewalReminderTestDB(t)\n\tsubscriptionRepo := repository.NewSubscriptionRepository(db)\n\tcategoryRepo := repository.NewCategoryRepository(db)\n\tcategoryService := NewCategoryService(categoryRepo)\n\tsubscriptionService := NewSubscriptionService(subscriptionRepo, categoryService)\n\n\tnow := time.Now()\n\trenewalDate := now.AddDate(0, 0, 5)       // 5 days from now\n\tlastReminderDate := now.AddDate(0, 0, -1) // 1 day ago\n\n\t// Create subscription with reminder already sent for this renewal date\n\tsub := &models.Subscription{\n\t\tName:                    \"Test Subscription\",\n\t\tCost:                    10.00,\n\t\tSchedule:                \"Monthly\",\n\t\tStatus:                  \"Active\",\n\t\tRenewalDate:             &renewalDate,\n\t\tLastReminderSent:        &lastReminderDate,\n\t\tLastReminderRenewalDate: &renewalDate, // Same as current renewal date\n\t}\n\terr := db.Create(sub).Error\n\tassert.NoError(t, err)\n\n\t// Get subscriptions needing reminders with 7 day window\n\tresult, err := subscriptionService.GetSubscriptionsNeedingReminders(7)\n\tassert.NoError(t, err)\n\tassert.Equal(t, 0, len(result), \"Should not find subscription that already has reminder sent for this renewal date\")\n\n\t// Now update the renewal date (simulating renewal date change)\n\tnewRenewalDate := now.AddDate(0, 0, 10) // 10 days from now\n\tsub.RenewalDate = &newRenewalDate\n\terr = db.Save(sub).Error\n\tassert.NoError(t, err)\n\n\t// Should still not find it (outside reminder window)\n\tresult, err = subscriptionService.GetSubscriptionsNeedingReminders(7)\n\tassert.NoError(t, err)\n\tassert.Equal(t, 0, len(result), \"Should not find subscription outside reminder window\")\n\n\t// Update to within window with different renewal date\n\tnewRenewalDate2 := now.AddDate(0, 0, 3) // 3 days from now\n\tsub.RenewalDate = &newRenewalDate2\n\terr = db.Save(sub).Error\n\tassert.NoError(t, err)\n\n\t// Should find it now because renewal date changed (different from LastReminderRenewalDate)\n\tresult, err = subscriptionService.GetSubscriptionsNeedingReminders(7)\n\tassert.NoError(t, err)\n\tassert.Equal(t, 1, len(result), \"Should find subscription when renewal date changes\")\n}\n\nfunc TestSubscriptionService_GetSubscriptionsNeedingReminders_ReminderDisabled(t *testing.T) {\n\tdb := setupRenewalReminderTestDB(t)\n\tsubscriptionRepo := repository.NewSubscriptionRepository(db)\n\tcategoryRepo := repository.NewCategoryRepository(db)\n\tcategoryService := NewCategoryService(categoryRepo)\n\tsubscriptionService := NewSubscriptionService(subscriptionRepo, categoryService)\n\n\tnow := time.Now()\n\trenewalDate := now.AddDate(0, 0, 5)\n\n\t// Create subscription with reminders disabled\n\tsub := &models.Subscription{\n\t\tName:            \"No Reminders Sub\",\n\t\tCost:            10.00,\n\t\tSchedule:        \"Monthly\",\n\t\tStatus:          \"Active\",\n\t\tRenewalDate:     &renewalDate,\n\t\tReminderEnabled: true,\n\t}\n\terr := db.Create(sub).Error\n\tassert.NoError(t, err)\n\t// Explicitly disable after create (GORM skips false for default:true fields)\n\tdb.Model(sub).Update(\"reminder_enabled\", false)\n\n\t// Should not be included in reminders\n\tresult, err := subscriptionService.GetSubscriptionsNeedingReminders(7)\n\tassert.NoError(t, err)\n\tassert.Equal(t, 0, len(result), \"Should not find subscription with reminders disabled\")\n\n\t// Create subscription with reminders enabled\n\tsub2 := &models.Subscription{\n\t\tName:            \"With Reminders Sub\",\n\t\tCost:            20.00,\n\t\tSchedule:        \"Monthly\",\n\t\tStatus:          \"Active\",\n\t\tRenewalDate:     &renewalDate,\n\t\tReminderEnabled: true,\n\t}\n\terr = db.Create(sub2).Error\n\tassert.NoError(t, err)\n\n\t// Should find only the enabled one\n\tresult, err = subscriptionService.GetSubscriptionsNeedingReminders(7)\n\tassert.NoError(t, err)\n\tassert.Equal(t, 1, len(result), \"Should only find subscription with reminders enabled\")\n}\n\n// Helper function to create time pointer\nfunc timePtr(t time.Time) *time.Time {\n\treturn &t\n}\n"
  },
  {
    "path": "internal/service/session.go",
    "content": "package service\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gorilla/sessions\"\n)\n\nconst (\n\tSessionName     = \"subtrackr_session\"\n\tSessionUserKey  = \"user_authenticated\"\n\tSessionMaxAge   = 24 * 60 * 60 // 24 hours in seconds\n\tRememberMeMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds\n)\n\ntype SessionService struct {\n\tstore *sessions.CookieStore\n}\n\n// NewSessionService creates a new session service\nfunc NewSessionService(secretKey string) *SessionService {\n\tstore := sessions.NewCookieStore([]byte(secretKey))\n\n\t// Configure session options\n\tstore.Options = &sessions.Options{\n\t\tPath:     \"/\",\n\t\tMaxAge:   SessionMaxAge,\n\t\tHttpOnly: true,\n\t\tSecure:   false, // Set to true if using HTTPS\n\t\tSameSite: http.SameSiteStrictMode,\n\t}\n\n\treturn &SessionService{store: store}\n}\n\n// CreateSession creates a new authenticated session\nfunc (s *SessionService) CreateSession(w http.ResponseWriter, r *http.Request, rememberMe bool) error {\n\tsession, err := s.store.Get(r, SessionName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsession.Values[SessionUserKey] = true\n\n\t// Extend session if \"remember me\" is checked\n\tif rememberMe {\n\t\tsession.Options.MaxAge = RememberMeMaxAge\n\t} else {\n\t\tsession.Options.MaxAge = SessionMaxAge\n\t}\n\n\treturn session.Save(r, w)\n}\n\n// IsAuthenticated checks if the user is authenticated\nfunc (s *SessionService) IsAuthenticated(r *http.Request) bool {\n\tsession, err := s.store.Get(r, SessionName)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tauth, ok := session.Values[SessionUserKey].(bool)\n\treturn ok && auth\n}\n\n// DestroySession destroys the user session\nfunc (s *SessionService) DestroySession(w http.ResponseWriter, r *http.Request) error {\n\tsession, err := s.store.Get(r, SessionName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Mark session as expired\n\tsession.Options.MaxAge = -1\n\tdelete(session.Values, SessionUserKey)\n\n\treturn session.Save(r, w)\n}\n\n// RefreshSession extends the session expiration\nfunc (s *SessionService) RefreshSession(w http.ResponseWriter, r *http.Request) error {\n\tsession, err := s.store.Get(r, SessionName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Only refresh if authenticated\n\tif auth, ok := session.Values[SessionUserKey].(bool); ok && auth {\n\t\t// Extend the max age\n\t\tcurrentMaxAge := session.Options.MaxAge\n\t\tif currentMaxAge > 0 {\n\t\t\tsession.Options.MaxAge = currentMaxAge\n\t\t}\n\t\treturn session.Save(r, w)\n\t}\n\n\treturn nil\n}\n\n// UpdateSessionExpiry updates the session secret (useful when secret changes)\nfunc (s *SessionService) UpdateSessionExpiry(maxAge int) {\n\ts.store.Options.MaxAge = maxAge\n}\n\n// GetSession retrieves the current session\nfunc (s *SessionService) GetSession(r *http.Request) (*sessions.Session, error) {\n\treturn s.store.Get(r, SessionName)\n}\n"
  },
  {
    "path": "internal/service/settings.go",
    "content": "package service\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/subtle\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"time\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\ntype SettingsService struct {\n\trepo *repository.SettingsRepository\n}\n\nfunc NewSettingsService(repo *repository.SettingsRepository) *SettingsService {\n\treturn &SettingsService{repo: repo}\n}\n\n// SaveSMTPConfig saves SMTP configuration\nfunc (s *SettingsService) SaveSMTPConfig(config *models.SMTPConfig) error {\n\t// Convert to JSON\n\tdata, err := json.Marshal(config)\n\tif err != nil {\n\t\treturn err\n\t}\n\t\n\treturn s.repo.Set(\"smtp_config\", string(data))\n}\n\n// GetSMTPConfig retrieves SMTP configuration\nfunc (s *SettingsService) GetSMTPConfig() (*models.SMTPConfig, error) {\n\tdata, err := s.repo.Get(\"smtp_config\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tvar config models.SMTPConfig\n\terr = json.Unmarshal([]byte(data), &config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\treturn &config, nil\n}\n\n// SetBoolSetting saves a boolean setting\nfunc (s *SettingsService) SetBoolSetting(key string, value bool) error {\n\treturn s.repo.Set(key, fmt.Sprintf(\"%t\", value))\n}\n\n// GetBoolSetting retrieves a boolean setting\nfunc (s *SettingsService) GetBoolSetting(key string, defaultValue bool) (bool, error) {\n\tvalue, err := s.repo.Get(key)\n\tif err != nil {\n\t\treturn defaultValue, err\n\t}\n\t\n\treturn value == \"true\", nil\n}\n\n// GetBoolSettingWithDefault retrieves a boolean setting with default\nfunc (s *SettingsService) GetBoolSettingWithDefault(key string, defaultValue bool) bool {\n\tvalue, err := s.GetBoolSetting(key, defaultValue)\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\treturn value\n}\n\n// SetIntSetting saves an integer setting\nfunc (s *SettingsService) SetIntSetting(key string, value int) error {\n\treturn s.repo.Set(key, strconv.Itoa(value))\n}\n\n// GetIntSetting retrieves an integer setting\nfunc (s *SettingsService) GetIntSetting(key string, defaultValue int) (int, error) {\n\tvalue, err := s.repo.Get(key)\n\tif err != nil {\n\t\treturn defaultValue, err\n\t}\n\t\n\tintValue, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn defaultValue, err\n\t}\n\t\n\treturn intValue, nil\n}\n\n// GetIntSettingWithDefault retrieves an integer setting with default\nfunc (s *SettingsService) GetIntSettingWithDefault(key string, defaultValue int) int {\n\tvalue, err := s.GetIntSetting(key, defaultValue)\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\treturn value\n}\n\n// SetFloatSetting saves a float setting\nfunc (s *SettingsService) SetFloatSetting(key string, value float64) error {\n\treturn s.repo.Set(key, fmt.Sprintf(\"%.2f\", value))\n}\n\n// GetFloatSetting retrieves a float setting\nfunc (s *SettingsService) GetFloatSetting(key string, defaultValue float64) (float64, error) {\n\tvalue, err := s.repo.Get(key)\n\tif err != nil {\n\t\treturn defaultValue, err\n\t}\n\n\tfloatValue, err := strconv.ParseFloat(value, 64)\n\tif err != nil {\n\t\treturn defaultValue, err\n\t}\n\n\treturn floatValue, nil\n}\n\n// GetTheme retrieves the current theme setting\nfunc (s *SettingsService) GetTheme() (string, error) {\n\ttheme, err := s.repo.Get(\"theme\")\n\tif err != nil {\n\t\treturn \"default\", err\n\t}\n\treturn theme, nil\n}\n\n// SetTheme saves the theme preference\nfunc (s *SettingsService) SetTheme(theme string) error {\n\treturn s.repo.Set(\"theme\", theme)\n}\n\n// GetFloatSettingWithDefault retrieves a float setting with default\nfunc (s *SettingsService) GetFloatSettingWithDefault(key string, defaultValue float64) float64 {\n\tvalue, err := s.GetFloatSetting(key, defaultValue)\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\treturn value\n}\n\n// CreateAPIKey creates a new API key\nfunc (s *SettingsService) CreateAPIKey(name, key string) (*models.APIKey, error) {\n\tapiKey := &models.APIKey{\n\t\tName: name,\n\t\tKey:  key,\n\t}\n\treturn s.repo.CreateAPIKey(apiKey)\n}\n\n// GetAllAPIKeys retrieves all API keys\nfunc (s *SettingsService) GetAllAPIKeys() ([]models.APIKey, error) {\n\treturn s.repo.GetAllAPIKeys()\n}\n\n// DeleteAPIKey deletes an API key\nfunc (s *SettingsService) DeleteAPIKey(id uint) error {\n\treturn s.repo.DeleteAPIKey(id)\n}\n\n// ValidateAPIKey checks if an API key is valid and updates usage\nfunc (s *SettingsService) ValidateAPIKey(key string) (*models.APIKey, error) {\n\tapiKey, err := s.repo.GetAPIKeyByKey(key)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// Update usage stats\n\terr = s.repo.UpdateAPIKeyUsage(apiKey.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\treturn apiKey, nil\n}\n\n// SetCurrency saves the currency preference\nfunc (s *SettingsService) SetCurrency(currency string) error {\n\t// Validate against known currencies\n\tif _, ok := currencyInfoMap[currency]; !ok {\n\t\treturn fmt.Errorf(\"invalid currency: %s\", currency)\n\t}\n\treturn s.repo.Set(\"currency\", currency)\n}\n\n// GetCurrency retrieves the currency preference\nfunc (s *SettingsService) GetCurrency() string {\n\tcurrency, err := s.repo.Get(\"currency\")\n\tif err != nil || currency == \"\" {\n\t\treturn \"USD\" // Default to USD\n\t}\n\treturn currency\n}\n\n// CurrencySymbolForCode returns the symbol for a given currency code\nfunc CurrencySymbolForCode(currency string) string {\n\treturn GetCurrencyInfo(currency).Symbol\n}\n\n// GetCurrencySymbol returns the symbol for the current currency\nfunc (s *SettingsService) GetCurrencySymbol() string {\n\treturn CurrencySymbolForCode(s.GetCurrency())\n}\n\n// SetDateFormat saves the date format preference\nfunc (s *SettingsService) SetDateFormat(format string) error {\n\tswitch format {\n\tcase \"MM/DD/YYYY\", \"DD/MM/YYYY\", \"YYYY-MM-DD\":\n\t\treturn s.repo.Set(\"date_format\", format)\n\tdefault:\n\t\treturn fmt.Errorf(\"invalid date format: %s\", format)\n\t}\n}\n\n// GetDateFormat retrieves the date format preference\nfunc (s *SettingsService) GetDateFormat() string {\n\tformat, err := s.repo.Get(\"date_format\")\n\tif err != nil || format == \"\" {\n\t\treturn \"MM/DD/YYYY\"\n\t}\n\treturn format\n}\n\n// GetGoDateFormat returns the Go time format string for the current date format\nfunc (s *SettingsService) GetGoDateFormat() string {\n\treturn DateFormatToGo(s.GetDateFormat())\n}\n\n// GetGoDateFormatLong returns the long Go time format string for emails/notifications\nfunc (s *SettingsService) GetGoDateFormatLong() string {\n\treturn DateFormatToGoLong(s.GetDateFormat())\n}\n\n// DateFormatToGo converts a date format key to a short Go time format string\nfunc DateFormatToGo(format string) string {\n\tswitch format {\n\tcase \"DD/MM/YYYY\":\n\t\treturn \"02/01/2006\"\n\tcase \"YYYY-MM-DD\":\n\t\treturn \"2006-01-02\"\n\tdefault:\n\t\treturn \"01/02/2006\"\n\t}\n}\n\n// DateFormatToGoLong converts a date format key to a long Go time format string\nfunc DateFormatToGoLong(format string) string {\n\tswitch format {\n\tcase \"DD/MM/YYYY\":\n\t\treturn \"2 January 2006\"\n\tcase \"YYYY-MM-DD\":\n\t\treturn \"2006-01-02\"\n\tdefault:\n\t\treturn \"January 2, 2006\"\n\t}\n}\n\n// SetDarkMode saves the dark mode preference\nfunc (s *SettingsService) SetDarkMode(enabled bool) error {\n\treturn s.SetBoolSetting(\"dark_mode\", enabled)\n}\n\n// IsDarkModeEnabled returns whether dark mode is enabled\nfunc (s *SettingsService) IsDarkModeEnabled() bool {\n\treturn s.GetBoolSettingWithDefault(\"dark_mode\", false)\n}\n\n// Auth-related methods\n\n// IsAuthEnabled returns whether authentication is enabled\nfunc (s *SettingsService) IsAuthEnabled() bool {\n\treturn s.GetBoolSettingWithDefault(\"auth_enabled\", false)\n}\n\n// SetAuthEnabled enables or disables authentication\nfunc (s *SettingsService) SetAuthEnabled(enabled bool) error {\n\treturn s.SetBoolSetting(\"auth_enabled\", enabled)\n}\n\n// GetAuthUsername returns the configured admin username\nfunc (s *SettingsService) GetAuthUsername() (string, error) {\n\treturn s.repo.Get(\"auth_username\")\n}\n\n// SetAuthUsername sets the admin username\nfunc (s *SettingsService) SetAuthUsername(username string) error {\n\treturn s.repo.Set(\"auth_username\", username)\n}\n\n// HashPassword hashes a password using bcrypt\nfunc (s *SettingsService) HashPassword(password string) (string, error) {\n\thash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(hash), nil\n}\n\n// SetAuthPassword hashes and stores the admin password\nfunc (s *SettingsService) SetAuthPassword(password string) error {\n\thash, err := s.HashPassword(password)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.repo.Set(\"auth_password_hash\", hash)\n}\n\n// ValidatePassword checks if a password matches the stored hash\nfunc (s *SettingsService) ValidatePassword(password string) error {\n\thash, err := s.repo.Get(\"auth_password_hash\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"no password configured\")\n\t}\n\treturn bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))\n}\n\n// GetOrGenerateSessionSecret returns the session secret, generating one if it doesn't exist\nfunc (s *SettingsService) GetOrGenerateSessionSecret() (string, error) {\n\tsecret, err := s.repo.Get(\"auth_session_secret\")\n\tif err == nil && secret != \"\" {\n\t\treturn secret, nil\n\t}\n\n\t// Generate a new 64-byte random secret\n\tbytes := make([]byte, 64)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\treturn \"\", err\n\t}\n\tsecret = base64.URLEncoding.EncodeToString(bytes)\n\n\t// Save it\n\tif err := s.repo.Set(\"auth_session_secret\", secret); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn secret, nil\n}\n\n// SetupAuth sets up authentication with username and password\nfunc (s *SettingsService) SetupAuth(username, password string) error {\n\t// Set username\n\tif err := s.SetAuthUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\t// Set password\n\tif err := s.SetAuthPassword(password); err != nil {\n\t\treturn err\n\t}\n\n\t// Generate session secret\n\tif _, err := s.GetOrGenerateSessionSecret(); err != nil {\n\t\treturn err\n\t}\n\n\t// Enable auth\n\treturn s.SetAuthEnabled(true)\n}\n\n// DisableAuth disables authentication and removes credentials\nfunc (s *SettingsService) DisableAuth() error {\n\t// Disable auth first\n\tif err := s.SetAuthEnabled(false); err != nil {\n\t\treturn err\n\t}\n\n\t// Optionally clear credentials (commented out to allow re-enabling without re-entering)\n\t// s.repo.Delete(\"auth_username\")\n\t// s.repo.Delete(\"auth_password_hash\")\n\n\treturn nil\n}\n\n// GenerateResetToken generates a password reset token\nfunc (s *SettingsService) GenerateResetToken() (string, error) {\n\tbytes := make([]byte, 32)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\treturn \"\", err\n\t}\n\ttoken := base64.URLEncoding.EncodeToString(bytes)\n\n\t// Store token with 1-hour expiry\n\tif err := s.repo.Set(\"auth_reset_token\", token); err != nil {\n\t\treturn \"\", err\n\t}\n\n\texpiry := time.Now().Add(1 * time.Hour).Format(time.RFC3339)\n\tif err := s.repo.Set(\"auth_reset_token_expiry\", expiry); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn token, nil\n}\n\n// ValidateResetToken checks if a reset token is valid\nfunc (s *SettingsService) ValidateResetToken(token string) error {\n\tstoredToken, err := s.repo.Get(\"auth_reset_token\")\n\tif err != nil || subtle.ConstantTimeCompare([]byte(storedToken), []byte(token)) != 1 {\n\t\treturn fmt.Errorf(\"invalid token\")\n\t}\n\n\texpiryStr, err := s.repo.Get(\"auth_reset_token_expiry\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"token expired\")\n\t}\n\n\texpiry, err := time.Parse(time.RFC3339, expiryStr)\n\tif err != nil || time.Now().After(expiry) {\n\t\treturn fmt.Errorf(\"token expired\")\n\t}\n\n\treturn nil\n}\n\n// ClearResetToken removes the reset token after use\nfunc (s *SettingsService) ClearResetToken() error {\n\ts.repo.Delete(\"auth_reset_token\")\n\ts.repo.Delete(\"auth_reset_token_expiry\")\n\treturn nil\n}\n\n// GetBaseURL returns the configured base URL for external links, or empty string if not set\nfunc (s *SettingsService) GetBaseURL() string {\n\tbaseURL, err := s.repo.Get(\"base_url\")\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn baseURL\n}\n\n// SetBaseURL saves the base URL setting\nfunc (s *SettingsService) SetBaseURL(baseURL string) error {\n\treturn s.repo.Set(\"base_url\", baseURL)\n}\n\n// iCal Subscription methods\n\n// IsICalSubscriptionEnabled returns whether iCal subscription is enabled\nfunc (s *SettingsService) IsICalSubscriptionEnabled() bool {\n\treturn s.GetBoolSettingWithDefault(\"ical_subscription_enabled\", false)\n}\n\n// SetICalSubscriptionEnabled enables or disables iCal subscription\nfunc (s *SettingsService) SetICalSubscriptionEnabled(enabled bool) error {\n\treturn s.SetBoolSetting(\"ical_subscription_enabled\", enabled)\n}\n\n// GetOrGenerateICalToken returns the iCal token, generating one if it doesn't exist\nfunc (s *SettingsService) GetOrGenerateICalToken() (string, error) {\n\ttoken, err := s.repo.Get(\"ical_subscription_token\")\n\tif err == nil && token != \"\" {\n\t\treturn token, nil\n\t}\n\n\t// Generate a new 32-byte random token\n\tbytes := make([]byte, 32)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\treturn \"\", err\n\t}\n\ttoken = base64.URLEncoding.EncodeToString(bytes)\n\n\tif err := s.repo.Set(\"ical_subscription_token\", token); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn token, nil\n}\n\n// RegenerateICalToken replaces the iCal token with a new one\nfunc (s *SettingsService) RegenerateICalToken() (string, error) {\n\tbytes := make([]byte, 32)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\treturn \"\", err\n\t}\n\ttoken := base64.URLEncoding.EncodeToString(bytes)\n\n\tif err := s.repo.Set(\"ical_subscription_token\", token); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn token, nil\n}\n\n// ValidateICalToken checks if a given token matches the stored iCal token\nfunc (s *SettingsService) ValidateICalToken(token string) bool {\n\tstoredToken, err := s.repo.Get(\"ical_subscription_token\")\n\tif err != nil || storedToken == \"\" {\n\t\treturn false\n\t}\n\treturn subtle.ConstantTimeCompare([]byte(storedToken), []byte(token)) == 1\n}\n\n// SavePushoverConfig saves Pushover configuration\nfunc (s *SettingsService) SavePushoverConfig(config *models.PushoverConfig) error {\n\t// Convert to JSON\n\tdata, err := json.Marshal(config)\n\tif err != nil {\n\t\treturn err\n\t}\n\t\n\treturn s.repo.Set(\"pushover_config\", string(data))\n}\n\n// GetPushoverConfig retrieves Pushover configuration\nfunc (s *SettingsService) GetPushoverConfig() (*models.PushoverConfig, error) {\n\tdata, err := s.repo.Get(\"pushover_config\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar config models.PushoverConfig\n\terr = json.Unmarshal([]byte(data), &config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &config, nil\n}\n\n// SaveWebhookConfig saves Webhook configuration\nfunc (s *SettingsService) SaveWebhookConfig(config *models.WebhookConfig) error {\n\tdata, err := json.Marshal(config)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.repo.Set(\"webhook_config\", string(data))\n}\n\n// GetWebhookConfig retrieves Webhook configuration\nfunc (s *SettingsService) GetWebhookConfig() (*models.WebhookConfig, error) {\n\tdata, err := s.repo.Get(\"webhook_config\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar config models.WebhookConfig\n\terr = json.Unmarshal([]byte(data), &config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &config, nil\n}\n"
  },
  {
    "path": "internal/service/settings_test.go",
    "content": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc setupSettingsTestDB(t *testing.T) *SettingsService {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\terr = db.AutoMigrate(&models.Settings{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\treturn NewSettingsService(settingsRepo)\n}\n\nfunc TestSetDateFormat_Valid(t *testing.T) {\n\ts := setupSettingsTestDB(t)\n\n\ttests := []struct {\n\t\tname   string\n\t\tformat string\n\t}{\n\t\t{\"US format\", \"MM/DD/YYYY\"},\n\t\t{\"European format\", \"DD/MM/YYYY\"},\n\t\t{\"ISO format\", \"YYYY-MM-DD\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := s.SetDateFormat(tt.format)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tresult := s.GetDateFormat()\n\t\t\tassert.Equal(t, tt.format, result)\n\t\t})\n\t}\n}\n\nfunc TestSetDateFormat_Invalid(t *testing.T) {\n\ts := setupSettingsTestDB(t)\n\n\ttests := []struct {\n\t\tname   string\n\t\tformat string\n\t}{\n\t\t{\"Empty string\", \"\"},\n\t\t{\"Random string\", \"foobar\"},\n\t\t{\"Close but wrong\", \"MM-DD-YYYY\"},\n\t\t{\"Lowercase\", \"mm/dd/yyyy\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := s.SetDateFormat(tt.format)\n\t\t\tassert.Error(t, err)\n\t\t\tassert.Contains(t, err.Error(), \"invalid date format\")\n\t\t})\n\t}\n}\n\nfunc TestGetDateFormat_Default(t *testing.T) {\n\ts := setupSettingsTestDB(t)\n\n\tformat := s.GetDateFormat()\n\tassert.Equal(t, \"MM/DD/YYYY\", format, \"Default date format should be MM/DD/YYYY\")\n}\n\nfunc TestDateFormatToGo(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"MM/DD/YYYY\", \"01/02/2006\"},\n\t\t{\"DD/MM/YYYY\", \"02/01/2006\"},\n\t\t{\"YYYY-MM-DD\", \"2006-01-02\"},\n\t\t{\"unknown\", \"01/02/2006\"}, // defaults to US\n\t\t{\"\", \"01/02/2006\"},        // defaults to US\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tassert.Equal(t, tt.expected, DateFormatToGo(tt.input))\n\t\t})\n\t}\n}\n\nfunc TestDateFormatToGoLong(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"MM/DD/YYYY\", \"January 2, 2006\"},\n\t\t{\"DD/MM/YYYY\", \"2 January 2006\"},\n\t\t{\"YYYY-MM-DD\", \"2006-01-02\"},\n\t\t{\"unknown\", \"January 2, 2006\"}, // defaults to US\n\t\t{\"\", \"January 2, 2006\"},        // defaults to US\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tassert.Equal(t, tt.expected, DateFormatToGoLong(tt.input))\n\t\t})\n\t}\n}\n\nfunc TestGetGoDateFormat(t *testing.T) {\n\ts := setupSettingsTestDB(t)\n\n\t// Default\n\tassert.Equal(t, \"01/02/2006\", s.GetGoDateFormat())\n\n\t// Set to European\n\ts.SetDateFormat(\"DD/MM/YYYY\")\n\tassert.Equal(t, \"02/01/2006\", s.GetGoDateFormat())\n\n\t// Set to ISO\n\ts.SetDateFormat(\"YYYY-MM-DD\")\n\tassert.Equal(t, \"2006-01-02\", s.GetGoDateFormat())\n}\n\nfunc TestGetGoDateFormatLong(t *testing.T) {\n\ts := setupSettingsTestDB(t)\n\n\t// Default\n\tassert.Equal(t, \"January 2, 2006\", s.GetGoDateFormatLong())\n\n\t// Set to European\n\ts.SetDateFormat(\"DD/MM/YYYY\")\n\tassert.Equal(t, \"2 January 2006\", s.GetGoDateFormatLong())\n}\n\nfunc TestWebhookConfig_SaveAndRetrieve(t *testing.T) {\n\ts := setupSettingsTestDB(t)\n\n\tconfig := &models.WebhookConfig{\n\t\tURL: \"https://example.com/webhook\",\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer test-token\",\n\t\t\t\"X-Custom\":      \"value\",\n\t\t},\n\t}\n\n\terr := s.SaveWebhookConfig(config)\n\tassert.NoError(t, err)\n\n\tretrieved, err := s.GetWebhookConfig()\n\tassert.NoError(t, err)\n\tassert.Equal(t, config.URL, retrieved.URL)\n\tassert.Equal(t, config.Headers, retrieved.Headers)\n}\n\nfunc TestWebhookConfig_NotConfigured(t *testing.T) {\n\ts := setupSettingsTestDB(t)\n\n\t_, err := s.GetWebhookConfig()\n\tassert.Error(t, err, \"Should error when webhook not configured\")\n}\n"
  },
  {
    "path": "internal/service/subscription.go",
    "content": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"time\"\n)\n\ntype SubscriptionService struct {\n\trepo            *repository.SubscriptionRepository\n\tcategoryService *CategoryService\n}\n\nfunc NewSubscriptionService(repo *repository.SubscriptionRepository, categoryService *CategoryService) *SubscriptionService {\n\treturn &SubscriptionService{repo: repo, categoryService: categoryService}\n}\n\nfunc (s *SubscriptionService) Create(subscription *models.Subscription) (*models.Subscription, error) {\n\treturn s.repo.Create(subscription)\n}\n\nfunc (s *SubscriptionService) GetAll() ([]models.Subscription, error) {\n\treturn s.repo.GetAll()\n}\n\nfunc (s *SubscriptionService) GetAllSorted(sortBy, order string) ([]models.Subscription, error) {\n\treturn s.repo.GetAllSorted(sortBy, order)\n}\n\nfunc (s *SubscriptionService) GetByID(id uint) (*models.Subscription, error) {\n\treturn s.repo.GetByID(id)\n}\n\nfunc (s *SubscriptionService) Update(id uint, subscription *models.Subscription) (*models.Subscription, error) {\n\treturn s.repo.Update(id, subscription)\n}\n\nfunc (s *SubscriptionService) Delete(id uint) error {\n\treturn s.repo.Delete(id)\n}\n\nfunc (s *SubscriptionService) Count() int64 {\n\treturn s.repo.Count()\n}\n\nfunc (s *SubscriptionService) GetStats() (*models.Stats, error) {\n\tactiveSubscriptions, err := s.repo.GetActiveSubscriptions()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcancelledSubscriptions, err := s.repo.GetCancelledSubscriptions()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tupcomingRenewals, err := s.repo.GetUpcomingRenewals(7)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcategoryStats, err := s.repo.GetCategoryStats()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tstats := &models.Stats{\n\t\tActiveSubscriptions:    len(activeSubscriptions),\n\t\tCancelledSubscriptions: len(cancelledSubscriptions),\n\t\tUpcomingRenewals:       len(upcomingRenewals),\n\t\tCategorySpending:       make(map[string]float64),\n\t}\n\n\t// Calculate totals\n\tfor _, sub := range activeSubscriptions {\n\t\tstats.TotalMonthlySpend += sub.MonthlyCost()\n\t\tstats.TotalAnnualSpend += sub.AnnualCost()\n\t}\n\n\t// Calculate savings from cancelled subscriptions\n\tfor _, sub := range cancelledSubscriptions {\n\t\tstats.TotalSaved += sub.AnnualCost()\n\t\tstats.MonthlySaved += sub.MonthlyCost()\n\t}\n\n\t// Build category spending map\n\tfor _, cat := range categoryStats {\n\t\tstats.CategorySpending[cat.Category] = cat.Amount\n\t}\n\n\treturn stats, nil\n}\n\nfunc (s *SubscriptionService) GetAllCategories() ([]models.Category, error) {\n\treturn s.categoryService.GetAll()\n}\n\n// GetSubscriptionsNeedingReminders returns subscriptions that need renewal reminders\n// based on the reminder_days setting. It returns a map of subscription to days until renewal.\nfunc (s *SubscriptionService) GetSubscriptionsNeedingReminders(reminderDays int) (map[*models.Subscription]int, error) {\n\tif reminderDays <= 0 {\n\t\treturn make(map[*models.Subscription]int), nil\n\t}\n\n\t// Get all subscriptions with renewals in the next reminderDays\n\tsubscriptions, err := s.repo.GetUpcomingRenewals(reminderDays)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make(map[*models.Subscription]int)\n\n\tfor i := range subscriptions {\n\t\tsub := &subscriptions[i]\n\t\tif sub.RenewalDate == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif !sub.ReminderEnabled {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Calculate days until renewal using proper date arithmetic\n\t\t// Use time.Until for more accurate calculation (handles timezone differences better)\n\t\tdaysUntil := int(time.Until(*sub.RenewalDate).Hours() / 24)\n\n\t\t// Only include if within the reminder window and not past due\n\t\tif daysUntil >= 0 && daysUntil <= reminderDays {\n\t\t\t// Check if we've already sent a reminder for this renewal date\n\t\t\t// Skip if we've sent a reminder for the same renewal date\n\t\t\tif sub.LastReminderRenewalDate != nil &&\n\t\t\t\tsub.RenewalDate != nil &&\n\t\t\t\tsub.LastReminderRenewalDate.Equal(*sub.RenewalDate) {\n\t\t\t\t// Already sent reminder for this renewal date, skip\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresult[sub] = daysUntil\n\t\t}\n\t}\n\n\treturn result, nil\n}\n\n// GetSubscriptionsNeedingCancellationReminders returns subscriptions that need cancellation reminders\n// based on the cancellation_reminder_days setting. It returns a map of subscription to days until cancellation.\nfunc (s *SubscriptionService) GetSubscriptionsNeedingCancellationReminders(reminderDays int) (map[*models.Subscription]int, error) {\n\tif reminderDays <= 0 {\n\t\treturn make(map[*models.Subscription]int), nil\n\t}\n\n\t// Get all subscriptions with cancellations in the next reminderDays\n\tsubscriptions, err := s.repo.GetUpcomingCancellations(reminderDays)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make(map[*models.Subscription]int)\n\n\tfor i := range subscriptions {\n\t\tsub := &subscriptions[i]\n\t\tif sub.CancellationDate == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif !sub.ReminderEnabled {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Calculate days until cancellation\n\t\tdaysUntil := int(time.Until(*sub.CancellationDate).Hours() / 24)\n\n\t\t// Only include if within the reminder window and not past due\n\t\tif daysUntil >= 0 && daysUntil <= reminderDays {\n\t\t\t// Check if we've already sent a reminder for this cancellation date\n\t\t\tif sub.LastCancellationReminderDate != nil &&\n\t\t\t\tsub.CancellationDate != nil &&\n\t\t\t\tsub.LastCancellationReminderDate.Equal(*sub.CancellationDate) {\n\t\t\t\t// Already sent reminder for this cancellation date, skip\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresult[sub] = daysUntil\n\t\t}\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/service/webhook.go",
    "content": "package service\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"subtrackr/internal/models\"\n\t\"time\"\n)\n\n// WebhookService handles sending notifications via generic webhooks\ntype WebhookService struct {\n\tsettingsService *SettingsService\n}\n\n// NewWebhookService creates a new Webhook service\nfunc NewWebhookService(settingsService *SettingsService) *WebhookService {\n\treturn &WebhookService{\n\t\tsettingsService: settingsService,\n\t}\n}\n\n// WebhookPayload is the JSON body sent to webhook endpoints\ntype WebhookPayload struct {\n\tEvent        string               `json:\"event\"`\n\tTitle        string               `json:\"title\"`\n\tMessage      string               `json:\"message\"`\n\tSubscription *WebhookSubscription `json:\"subscription\"`\n\tTimestamp    string               `json:\"timestamp\"`\n}\n\n// WebhookSubscription is a simplified subscription for webhook payloads\ntype WebhookSubscription struct {\n\tID               uint    `json:\"id\"`\n\tName             string  `json:\"name\"`\n\tCost             float64 `json:\"cost\"`\n\tCurrency         string  `json:\"currency\"`\n\tCurrencySymbol   string  `json:\"currency_symbol\"`\n\tSchedule         string  `json:\"schedule\"`\n\tMonthlyCost      float64 `json:\"monthly_cost\"`\n\tCategory         string  `json:\"category,omitempty\"`\n\tURL              string  `json:\"url,omitempty\"`\n\tRenewalDate      string  `json:\"renewal_date,omitempty\"`\n\tCancellationDate string  `json:\"cancellation_date,omitempty\"`\n}\n\nfunc subscriptionToWebhook(sub *models.Subscription, settings *SettingsService) *WebhookSubscription {\n\tcurrencySymbol := currencySymbolForSubscription(sub, settings)\n\tws := &WebhookSubscription{\n\t\tID:             sub.ID,\n\t\tName:           sub.Name,\n\t\tCost:           sub.Cost,\n\t\tCurrency:       sub.OriginalCurrency,\n\t\tCurrencySymbol: currencySymbol,\n\t\tSchedule:       sub.Schedule,\n\t\tMonthlyCost:    sub.MonthlyCost(),\n\t}\n\tif sub.Category.Name != \"\" {\n\t\tws.Category = sub.Category.Name\n\t}\n\tif sub.URL != \"\" {\n\t\tws.URL = sub.URL\n\t}\n\tdateFormat := settings.GetGoDateFormat()\n\tif sub.RenewalDate != nil {\n\t\tws.RenewalDate = sub.RenewalDate.Format(dateFormat)\n\t}\n\tif sub.CancellationDate != nil {\n\t\tws.CancellationDate = sub.CancellationDate.Format(dateFormat)\n\t}\n\treturn ws\n}\n\n// SendWebhook sends a payload to the configured webhook endpoint\nfunc (w *WebhookService) SendWebhook(payload *WebhookPayload) error {\n\tconfig, err := w.settingsService.GetWebhookConfig()\n\tif err != nil || config.URL == \"\" {\n\t\treturn nil // Not configured, silently skip (matches email/pushover behavior)\n\t}\n\n\tjsonData, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal webhook payload: %w\", err)\n\t}\n\n\treq, err := http.NewRequest(\"POST\", config.URL, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"SubTrackr-Webhook/1.0\")\n\n\tfor key, value := range config.Headers {\n\t\treq.Header.Set(key, value)\n\t}\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send webhook: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\treturn fmt.Errorf(\"webhook returned status %d\", resp.StatusCode)\n\t}\n\n\treturn nil\n}\n\n// SendHighCostAlert sends a webhook alert when a high-cost subscription is created\nfunc (w *WebhookService) SendHighCostAlert(subscription *models.Subscription) error {\n\tenabled, err := w.settingsService.GetBoolSetting(\"high_cost_alerts\", true)\n\tif err != nil || !enabled {\n\t\treturn nil\n\t}\n\n\tcurrencySymbol := currencySymbolForSubscription(subscription, w.settingsService)\n\tpayload := &WebhookPayload{\n\t\tEvent:        \"high_cost_alert\",\n\t\tTitle:        fmt.Sprintf(\"High Cost Alert: %s\", subscription.Name),\n\t\tMessage:      fmt.Sprintf(\"A new high-cost subscription has been added: %s at %s%.2f %s\", subscription.Name, currencySymbol, subscription.Cost, subscription.Schedule),\n\t\tSubscription: subscriptionToWebhook(subscription, w.settingsService),\n\t\tTimestamp:    time.Now().UTC().Format(time.RFC3339),\n\t}\n\n\treturn w.SendWebhook(payload)\n}\n\n// SendRenewalReminder sends a webhook reminder for an upcoming subscription renewal\nfunc (w *WebhookService) SendRenewalReminder(subscription *models.Subscription, daysUntilRenewal int) error {\n\tenabled, err := w.settingsService.GetBoolSetting(\"renewal_reminders\", false)\n\tif err != nil || !enabled {\n\t\treturn nil\n\t}\n\n\tdaysText := \"days\"\n\tif daysUntilRenewal == 1 {\n\t\tdaysText = \"day\"\n\t}\n\tpayload := &WebhookPayload{\n\t\tEvent:        \"renewal_reminder\",\n\t\tTitle:        fmt.Sprintf(\"Renewal Reminder: %s\", subscription.Name),\n\t\tMessage:      fmt.Sprintf(\"Your subscription %s will renew in %d %s\", subscription.Name, daysUntilRenewal, daysText),\n\t\tSubscription: subscriptionToWebhook(subscription, w.settingsService),\n\t\tTimestamp:    time.Now().UTC().Format(time.RFC3339),\n\t}\n\n\treturn w.SendWebhook(payload)\n}\n\n// SendCancellationReminder sends a webhook reminder for an upcoming subscription cancellation\nfunc (w *WebhookService) SendCancellationReminder(subscription *models.Subscription, daysUntilCancellation int) error {\n\tenabled, err := w.settingsService.GetBoolSetting(\"cancellation_reminders\", false)\n\tif err != nil || !enabled {\n\t\treturn nil\n\t}\n\n\tdaysText := \"days\"\n\tif daysUntilCancellation == 1 {\n\t\tdaysText = \"day\"\n\t}\n\tpayload := &WebhookPayload{\n\t\tEvent:        \"cancellation_reminder\",\n\t\tTitle:        fmt.Sprintf(\"Cancellation Reminder: %s\", subscription.Name),\n\t\tMessage:      fmt.Sprintf(\"Your subscription %s will end in %d %s\", subscription.Name, daysUntilCancellation, daysText),\n\t\tSubscription: subscriptionToWebhook(subscription, w.settingsService),\n\t\tTimestamp:    time.Now().UTC().Format(time.RFC3339),\n\t}\n\n\treturn w.SendWebhook(payload)\n}\n"
  },
  {
    "path": "internal/service/webhook_test.go",
    "content": "package service\n\nimport (\n\t\"subtrackr/internal/models\"\n\t\"subtrackr/internal/repository\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc setupWebhookTestDB(t *testing.T) (*SettingsService, *WebhookService) {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\terr = db.AutoMigrate(&models.Settings{}, &models.Category{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\twebhookService := NewWebhookService(settingsService)\n\treturn settingsService, webhookService\n}\n\nfunc TestWebhookService_SendWebhook_NoConfig(t *testing.T) {\n\t_, ws := setupWebhookTestDB(t)\n\n\tpayload := &WebhookPayload{\n\t\tEvent:   \"test\",\n\t\tTitle:   \"Test\",\n\t\tMessage: \"Test message\",\n\t}\n\n\terr := ws.SendWebhook(payload)\n\tassert.NoError(t, err, \"Should silently skip when webhook is not configured\")\n}\n\nfunc TestWebhookService_SendWebhook_EmptyURL(t *testing.T) {\n\tss, ws := setupWebhookTestDB(t)\n\n\tconfig := &models.WebhookConfig{\n\t\tURL: \"\",\n\t}\n\tss.SaveWebhookConfig(config)\n\n\tpayload := &WebhookPayload{\n\t\tEvent:   \"test\",\n\t\tTitle:   \"Test\",\n\t\tMessage: \"Test message\",\n\t}\n\n\terr := ws.SendWebhook(payload)\n\tassert.NoError(t, err, \"Should silently skip when webhook URL is empty\")\n}\n\nfunc TestWebhookService_SendHighCostAlert_Disabled(t *testing.T) {\n\tss, ws := setupWebhookTestDB(t)\n\n\tss.SetBoolSetting(\"high_cost_alerts\", false)\n\n\tsub := &models.Subscription{\n\t\tName:     \"Test Sub\",\n\t\tCost:     100.00,\n\t\tSchedule: \"Monthly\",\n\t\tCategory: models.Category{Name: \"Test\"},\n\t}\n\n\terr := ws.SendHighCostAlert(sub)\n\tassert.NoError(t, err, \"Should return nil when high cost alerts are disabled\")\n}\n\nfunc TestWebhookService_SendHighCostAlert_EnabledNoConfig(t *testing.T) {\n\tss, ws := setupWebhookTestDB(t)\n\n\tss.SetBoolSetting(\"high_cost_alerts\", true)\n\tss.SetCurrency(\"USD\")\n\n\tsub := &models.Subscription{\n\t\tName:     \"Test Sub\",\n\t\tCost:     100.00,\n\t\tSchedule: \"Monthly\",\n\t\tCategory: models.Category{Name: \"Test\"},\n\t}\n\n\terr := ws.SendHighCostAlert(sub)\n\tassert.NoError(t, err, \"Should silently skip when webhook is not configured\")\n}\n\nfunc TestWebhookService_SendRenewalReminder_Disabled(t *testing.T) {\n\tss, ws := setupWebhookTestDB(t)\n\n\tss.SetBoolSetting(\"renewal_reminders\", false)\n\n\tsub := &models.Subscription{\n\t\tName:        \"Test Sub\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t\tCategory:    models.Category{Name: \"Test\"},\n\t}\n\n\terr := ws.SendRenewalReminder(sub, 3)\n\tassert.NoError(t, err, \"Should return nil when renewal reminders are disabled\")\n}\n\nfunc TestWebhookService_SendRenewalReminder_EnabledNoConfig(t *testing.T) {\n\tss, ws := setupWebhookTestDB(t)\n\n\tss.SetBoolSetting(\"renewal_reminders\", true)\n\tss.SetCurrency(\"USD\")\n\n\tsub := &models.Subscription{\n\t\tName:        \"Test Sub\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t\tCategory:    models.Category{Name: \"Test\"},\n\t}\n\n\terr := ws.SendRenewalReminder(sub, 3)\n\tassert.NoError(t, err, \"Should silently skip when webhook is not configured\")\n}\n\nfunc TestWebhookService_SendCancellationReminder_Disabled(t *testing.T) {\n\tss, ws := setupWebhookTestDB(t)\n\n\tss.SetBoolSetting(\"cancellation_reminders\", false)\n\n\tsub := &models.Subscription{\n\t\tName:             \"Test Sub\",\n\t\tCost:             10.00,\n\t\tSchedule:         \"Monthly\",\n\t\tCancellationDate: timePtr(time.Now().AddDate(0, 0, 5)),\n\t\tCategory:         models.Category{Name: \"Test\"},\n\t}\n\n\terr := ws.SendCancellationReminder(sub, 5)\n\tassert.NoError(t, err, \"Should return nil when cancellation reminders are disabled\")\n}\n\nfunc TestWebhookService_SendCancellationReminder_EnabledNoConfig(t *testing.T) {\n\tss, ws := setupWebhookTestDB(t)\n\n\tss.SetBoolSetting(\"cancellation_reminders\", true)\n\tss.SetCurrency(\"USD\")\n\n\tsub := &models.Subscription{\n\t\tName:             \"Test Sub\",\n\t\tCost:             10.00,\n\t\tSchedule:         \"Monthly\",\n\t\tCancellationDate: timePtr(time.Now().AddDate(0, 0, 5)),\n\t\tCategory:         models.Category{Name: \"Test\"},\n\t}\n\n\terr := ws.SendCancellationReminder(sub, 5)\n\tassert.NoError(t, err, \"Should silently skip when webhook is not configured\")\n}\n\nfunc TestSubscriptionToWebhook(t *testing.T) {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\terr = db.AutoMigrate(&models.Settings{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tsettingsService.SetCurrency(\"USD\")\n\n\trenewalDate := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)\n\tcancellationDate := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)\n\n\tsub := &models.Subscription{\n\t\tName:             \"Netflix\",\n\t\tCost:             15.99,\n\t\tOriginalCurrency: \"EUR\",\n\t\tSchedule:         \"Monthly\",\n\t\tCategory:         models.Category{Name: \"Entertainment\"},\n\t\tURL:              \"https://netflix.com\",\n\t\tRenewalDate:      &renewalDate,\n\t\tCancellationDate: &cancellationDate,\n\t}\n\tsub.ID = 42\n\n\tws := subscriptionToWebhook(sub, settingsService)\n\n\tassert.Equal(t, uint(42), ws.ID)\n\tassert.Equal(t, \"Netflix\", ws.Name)\n\tassert.Equal(t, 15.99, ws.Cost)\n\tassert.Equal(t, \"EUR\", ws.Currency)\n\tassert.Equal(t, \"€\", ws.CurrencySymbol)\n\tassert.Equal(t, \"Monthly\", ws.Schedule)\n\tassert.Equal(t, \"Entertainment\", ws.Category)\n\tassert.Equal(t, \"https://netflix.com\", ws.URL)\n\tassert.NotEmpty(t, ws.RenewalDate)\n\tassert.NotEmpty(t, ws.CancellationDate)\n}\n\nfunc TestSubscriptionToWebhook_MinimalFields(t *testing.T) {\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to open test database: %v\", err)\n\t}\n\terr = db.AutoMigrate(&models.Settings{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to migrate test database: %v\", err)\n\t}\n\n\tsettingsRepo := repository.NewSettingsRepository(db)\n\tsettingsService := NewSettingsService(settingsRepo)\n\tsettingsService.SetCurrency(\"USD\")\n\n\tsub := &models.Subscription{\n\t\tName:     \"Basic Sub\",\n\t\tCost:     5.00,\n\t\tSchedule: \"Monthly\",\n\t}\n\n\tws := subscriptionToWebhook(sub, settingsService)\n\n\tassert.Equal(t, \"Basic Sub\", ws.Name)\n\tassert.Equal(t, 5.00, ws.Cost)\n\tassert.Empty(t, ws.Category, \"Category should be empty when not set\")\n\tassert.Empty(t, ws.URL, \"URL should be empty when not set\")\n\tassert.Empty(t, ws.RenewalDate, \"RenewalDate should be empty when nil\")\n\tassert.Empty(t, ws.CancellationDate, \"CancellationDate should be empty when nil\")\n}\n\nfunc TestWebhookService_SendRenewalReminder_DaysText(t *testing.T) {\n\tss, ws := setupWebhookTestDB(t)\n\n\tss.SetBoolSetting(\"renewal_reminders\", true)\n\tss.SetCurrency(\"USD\")\n\n\tsub := &models.Subscription{\n\t\tName:        \"Test Sub\",\n\t\tCost:        10.00,\n\t\tSchedule:    \"Monthly\",\n\t\tRenewalDate: timePtr(time.Now().AddDate(0, 0, 3)),\n\t\tCategory:    models.Category{Name: \"Test\"},\n\t}\n\n\ttests := []struct {\n\t\tname      string\n\t\tdaysUntil int\n\t}{\n\t\t{\"Singular day\", 1},\n\t\t{\"Plural days\", 3},\n\t\t{\"Zero days\", 0},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := ws.SendRenewalReminder(sub, tt.daysUntil)\n\t\t\tassert.NoError(t, err, \"Should silently skip when webhook is not configured\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/version/version.go",
    "content": "package version\n\nvar (\n\t// GitCommit is the git commit SHA that will be set at build time\n\tGitCommit = \"unknown\"\n\t// Version is the semantic version tag that will be set at build time\n\tVersion = \"dev\"\n)\n\n// GetVersion returns the current version string\n// Prefers the semantic version tag over git commit SHA\nfunc GetVersion() string {\n\tif Version != \"dev\" && Version != \"\" {\n\t\treturn Version\n\t}\n\tif GitCommit != \"unknown\" && GitCommit != \"\" {\n\t\treturn GitCommit\n\t}\n\treturn \"dev\"\n}"
  },
  {
    "path": "package.json",
    "content": "{\n  \"dependencies\": {\n    \"@playwright/test\": \"^1.54.2\"\n  },\n  \"scripts\": {\n    \"test\": \"playwright test\",\n    \"test:headed\": \"playwright test --headed\",\n    \"test:ui\": \"playwright test --ui\",\n    \"test:report\": \"playwright show-report\"\n  }\n}\n"
  },
  {
    "path": "playwright.config.js",
    "content": "// @ts-check\nconst { defineConfig, devices } = require('@playwright/test');\n\n/**\n * @see https://playwright.dev/docs/test-configuration\n */\nmodule.exports = defineConfig({\n  testDir: './tests',\n  /* Run tests in files in parallel */\n  fullyParallel: true,\n  /* Fail the build on CI if you accidentally left test.only in the source code. */\n  forbidOnly: !!process.env.CI,\n  /* Retry on CI only */\n  retries: process.env.CI ? 2 : 0,\n  /* Opt out of parallel tests on CI. */\n  workers: process.env.CI ? 1 : undefined,\n  /* Reporter to use. See https://playwright.dev/docs/test-reporters */\n  reporter: 'html',\n  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */\n  use: {\n    /* Base URL to use in actions like `await page.goto('/')`. */\n    baseURL: 'http://localhost:8082',\n\n    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */\n    trace: 'on-first-retry',\n  },\n\n  /* Configure projects for major browsers */\n  projects: [\n    {\n      name: 'chromium',\n      use: { ...devices['Desktop Chrome'] },\n    },\n\n    {\n      name: 'firefox',\n      use: { ...devices['Desktop Firefox'] },\n    },\n\n    {\n      name: 'webkit',\n      use: { ...devices['Desktop Safari'] },\n    },\n\n    /* Test against mobile viewports. */\n    // {\n    //   name: 'Mobile Chrome',\n    //   use: { ...devices['Pixel 5'] },\n    // },\n    // {\n    //   name: 'Mobile Safari',\n    //   use: { ...devices['iPhone 12'] },\n    // },\n\n    /* Test against branded browsers. */\n    // {\n    //   name: 'Microsoft Edge',\n    //   use: { ...devices['Desktop Edge'], channel: 'msedge' },\n    // },\n    // {\n    //   name: 'Google Chrome',\n    //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },\n    // },\n  ],\n\n  /* Run your local dev server before starting the tests */\n  webServer: {\n    command: 'go run .',\n    url: 'http://localhost:8082',\n    reuseExistingServer: !process.env.CI,\n  },\n});"
  },
  {
    "path": "templates/analytics.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"theme-color\" content=\"#37889b\">\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/manifest.json\">\n    <title>{{.Title}} - SubTrackr</title>\n    <script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script>\n        tailwind.config = {\n            darkMode: 'class',\n            theme: {\n                extend: {\n                    colors: {\n                        'primary': '#3b82f6',\n                        'success': '#10b981',\n                        'warning': '#f59e0b',\n                        'danger': '#ef4444',\n                    }\n                }\n            }\n        }\n    </script>\n    <link rel=\"stylesheet\" href=\"/static/css/themes.css\">\n    <script src=\"/static/js/themes.js\"></script>\n    <script src=\"/static/js/mobile-menu.js\"></script>\n</head>\n<body class=\"bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200\">\n    <div class=\"flex flex-col min-h-screen\">\n        <!-- Header -->\n        <header class=\"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 transition-colors duration-200\">\n            <div class=\"flex items-center justify-between max-w-7xl mx-auto\">\n                <div class=\"flex items-center space-x-4 md:space-x-8\">\n                    <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                    <!-- Desktop Navigation -->\n                    <nav class=\"hidden md:flex space-x-1\">\n                        <a href=\"/\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                    </nav>\n                    <!-- Mobile Hamburger Button -->\n                    <button id=\"mobile-menu-button\" class=\"md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Open menu\">\n                        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 12h16M4 18h16\"></path>\n                        </svg>\n                    </button>\n                </div>\n                <!-- Desktop Actions -->\n                <div class=\"hidden md:flex items-center space-x-3\">\n                    <button \n                        hx-get=\"/form/subscription\"\n                        hx-target=\"#modal-content\"\n                        hx-trigger=\"click\"\n                        onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                        class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 flex items-center transition-colors duration-150\">\n                        <svg class=\"w-4 h-4 mr-1\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                        </svg>\n                        Add\n                    </button>\n                    <a href=\"/settings\" class=\"text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-150\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n                <!-- Mobile Actions (Settings only, Add is in menu) -->\n                <div class=\"md:hidden flex items-center\">\n                    <a href=\"/settings\" class=\"p-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-150\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n            </div>\n        </header>\n\n        <!-- Mobile Menu Overlay -->\n        <div id=\"mobile-menu\" class=\"hidden fixed inset-0 z-50 md:hidden\">\n            <!-- Backdrop -->\n            <div class=\"fixed inset-0 bg-black bg-opacity-50 transition-opacity\" onclick=\"closeMobileMenu()\"></div>\n            <!-- Menu Panel -->\n            <div class=\"fixed left-0 top-0 bottom-0 w-64 bg-white dark:bg-gray-800 shadow-xl transform transition-transform duration-300 ease-in-out\">\n                <div class=\"flex flex-col h-full\">\n                    <!-- Menu Header -->\n                    <div class=\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700\">\n                        <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                        <button onclick=\"closeMobileMenu()\" class=\"p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Close menu\">\n                            <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                            </svg>\n                        </button>\n                    </div>\n                    <!-- Menu Items -->\n                    <nav class=\"flex-1 overflow-y-auto p-4 space-y-2\">\n                        <a href=\"/\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                        <div class=\"pt-4 border-t border-gray-200 dark:border-gray-700 mt-4\">\n                            <button \n                                onclick=\"closeMobileMenuAndThen(function() { document.getElementById('modal').classList.remove('hidden'); });\"\n                                hx-get=\"/form/subscription\"\n                                hx-target=\"#modal-content\"\n                                hx-trigger=\"click\"\n                                class=\"w-full flex items-center justify-center px-4 py-3 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n                                <svg class=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                                </svg>\n                                Add Subscription\n                            </button>\n                        </div>\n                    </nav>\n                </div>\n            </div>\n        </div>\n\n        <!-- Main Content -->\n        <main class=\"flex-1 p-4\">\n            <div class=\"max-w-7xl mx-auto\">\n\n<!-- Stats Overview -->\n<div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <h3 class=\"text-sm font-medium text-gray-600 dark:text-gray-300 mb-2\">Total Monthly Spend</h3>\n        <p class=\"text-2xl font-bold text-primary\">{{.CurrencySymbol}}{{printf \"%.2f\" .Stats.TotalMonthlySpend}}</p>\n        <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">Across {{.Stats.ActiveSubscriptions}} active subscriptions</p>\n    </div>\n    \n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <h3 class=\"text-sm font-medium text-gray-600 dark:text-gray-300 mb-2\">Total Annual Spend</h3>\n        <p class=\"text-2xl font-bold text-success\">{{.CurrencySymbol}}{{printf \"%.2f\" .Stats.TotalAnnualSpend}}</p>\n        <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">Projected yearly cost</p>\n    </div>\n    \n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <h3 class=\"text-sm font-medium text-gray-600 dark:text-gray-300 mb-2\">Annual Savings</h3>\n        <p class=\"text-2xl font-bold text-danger\">{{.CurrencySymbol}}{{printf \"%.2f\" .Stats.TotalSaved}}</p>\n        <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">From {{.Stats.CancelledSubscriptions}} cancelled subscriptions</p>\n    </div>\n</div>\n\n<!-- Category Breakdown -->\n<div class=\"grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8\">\n    <!-- Category Spending Chart -->\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <h3 class=\"text-lg font-semibold text-gray-900 dark:text-white mb-6\">Spending by Category</h3>\n        <div class=\"space-y-4\">\n            {{range $category, $amount := .Stats.CategorySpending}}\n            <div class=\"flex items-center justify-between\">\n                <div class=\"flex items-center flex-1\">\n                    <div class=\"w-3 h-3 bg-primary rounded-full mr-3\"></div>\n                    <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200 min-w-0 flex-1\">{{$category}}</span>\n                </div>\n                <div class=\"flex items-center space-x-4 ml-4\">\n                    <div class=\"w-24 rounded-full h-2 overflow-hidden\" style=\"background-color: #e5e7eb;\">\n                        <div class=\"h-2 rounded-full transition-all duration-300\" \n                             style=\"width: {{printf \"%.0f\" (div (mul $amount 100.0) $.Stats.TotalMonthlySpend)}}%; background-color: #3b82f6;\"></div>\n                    </div>\n                    <span class=\"text-sm font-medium text-gray-900 dark:text-white w-16 text-right\">{{$.CurrencySymbol}}{{printf \"%.2f\" $amount}}</span>\n                </div>\n            </div>\n            {{end}}\n        </div>\n    </div>\n    \n    <!-- Subscription Status -->\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <h3 class=\"text-lg font-semibold text-gray-900 dark:text-white mb-6\">Subscription Status</h3>\n        <div class=\"space-y-4\">\n            <div class=\"flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/50 rounded-lg transition-colors duration-200\">\n                <div class=\"flex items-center\">\n                    <div class=\"w-3 h-3 bg-success rounded-full mr-3\"></div>\n                    <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">Active</span>\n                </div>\n                <span class=\"text-lg font-bold text-success\">{{.Stats.ActiveSubscriptions}}</span>\n            </div>\n            \n            <div class=\"flex items-center justify-between p-4 bg-red-50 dark:bg-red-900/50 rounded-lg transition-colors duration-200\">\n                <div class=\"flex items-center\">\n                    <div class=\"w-3 h-3 bg-danger rounded-full mr-3\"></div>\n                    <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">Cancelled</span>\n                </div>\n                <span class=\"text-lg font-bold text-danger\">{{.Stats.CancelledSubscriptions}}</span>\n            </div>\n            \n            <div class=\"flex items-center justify-between p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg transition-colors duration-200\">\n                <div class=\"flex items-center\">\n                    <div class=\"w-3 h-3 bg-warning rounded-full mr-3\"></div>\n                    <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">Upcoming Renewals</span>\n                </div>\n                <span class=\"text-lg font-bold text-warning\">{{.Stats.UpcomingRenewals}}</span>\n            </div>\n        </div>\n    </div>\n</div>\n\n<!-- Cost Analysis -->\n<div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n    <h3 class=\"text-lg font-semibold text-gray-900 dark:text-white mb-6\">Cost Analysis</h3>\n    <div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n        <div class=\"text-center p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg transition-colors duration-200\">\n            <p class=\"text-2xl font-bold text-primary\">{{.CurrencySymbol}}{{printf \"%.2f\" (div .Stats.TotalMonthlySpend 30)}}</p>\n            <p class=\"text-sm text-gray-600 dark:text-gray-300\">Average Daily Cost</p>\n        </div>\n        \n        <div class=\"text-center p-4 bg-green-50 dark:bg-green-900/50 rounded-lg transition-colors duration-200\">\n            <p class=\"text-2xl font-bold text-success\">{{.CurrencySymbol}}{{printf \"%.2f\" .Stats.TotalMonthlySpend}}</p>\n            <p class=\"text-sm text-gray-600 dark:text-gray-300\">Total Monthly Cost</p>\n        </div>\n    </div>\n</div>\n\n            </div>\n        </main>\n    </div>\n\n    <!-- Modal -->\n    <div id=\"modal\" class=\"hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50\">\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4 transition-colors duration-200\">\n            <div id=\"modal-content\">\n                <!-- Dynamic content loaded here -->\n            </div>\n        </div>\n    </div>\n\n    <script>\n        // Close modal when clicking outside\n        document.getElementById('modal').addEventListener('click', function(e) {\n            if (e.target === this) {\n                this.classList.add('hidden');\n            }\n        });\n    </script>\n</body>\n</html>"
  },
  {
    "path": "templates/api-keys-list.html",
    "content": "{{if .Keys}}\n    {{range .Keys}}\n    <div class=\"flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg\">\n        <div class=\"flex-1\">\n            <div class=\"flex items-center space-x-3\">\n                <h5 class=\"text-sm font-medium text-gray-900\">{{.Name}}</h5>\n                {{if .IsNew}}\n                <span class=\"px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded\">New</span>\n                {{end}}\n            </div>\n            {{if .IsNew}}\n            <div class=\"mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded\">\n                <p class=\"text-xs text-yellow-800 mb-1\">\n                    <strong>Important:</strong> Copy this API key now. You won't be able to see it again!\n                </p>\n                <div class=\"flex items-center space-x-2\">\n                    <code class=\"flex-1 text-xs bg-yellow-100 px-2 py-1 rounded font-mono\">{{.Key}}</code>\n                    <button onclick=\"navigator.clipboard.writeText('{{.Key}}')\" \n                            class=\"text-yellow-700 hover:text-yellow-900\">\n                        <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\"></path>\n                        </svg>\n                    </button>\n                </div>\n            </div>\n            {{else}}\n            <div class=\"text-xs text-gray-500 mt-1\">\n                Created: {{fmtTime .CreatedAt $.GoDateFormat}} •\n                {{if .LastUsed}}Last used: {{fmtTime .LastUsed $.GoDateFormat}}{{else}}Never used{{end}} •\n                Usage: {{.UsageCount}} requests\n            </div>\n            {{end}}\n        </div>\n        <button hx-delete=\"/api/settings/apikeys/{{.ID}}\"\n                hx-confirm=\"Are you sure you want to delete this API key?\"\n                hx-target=\"#api-keys-list\"\n                hx-swap=\"innerHTML\"\n                class=\"ml-4 text-gray-400 hover:text-danger\">\n            <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"></path>\n            </svg>\n        </button>\n    </div>\n    {{end}}\n{{else}}\n    <div class=\"text-center py-4 text-gray-500 bg-gray-50 rounded-lg\">\n        No API keys created yet\n    </div>\n{{end}}"
  },
  {
    "path": "templates/auth-message.html",
    "content": "{{if .Error}}\n<div class=\"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 transition-colors duration-200\">\n    <div class=\"flex items-center\">\n        <svg class=\"w-4 h-4 text-red-400 dark:text-red-500 mr-2\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n            <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z\" clip-rule=\"evenodd\"></path>\n        </svg>\n        <p class=\"text-sm text-red-700 dark:text-red-300\">{{.Error}}</p>\n    </div>\n</div>\n{{else if .Message}}\n<div class=\"bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 transition-colors duration-200\">\n    <div class=\"flex items-center\">\n        <svg class=\"w-4 h-4 text-green-400 dark:text-green-500 mr-2\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n            <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\" clip-rule=\"evenodd\"></path>\n        </svg>\n        <p class=\"text-sm text-green-700 dark:text-green-300\">{{.Message}}</p>\n    </div>\n</div>\n{{end}}\n"
  },
  {
    "path": "templates/calendar.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"theme-color\" content=\"#37889b\">\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/manifest.json\">\n    <title>{{.Title}} - SubTrackr</title>\n    <script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script>\n        tailwind.config = {\n            darkMode: 'class',\n            theme: {\n                extend: {\n                    colors: {\n                        'primary': '#3b82f6',\n                        'success': '#10b981',\n                        'warning': '#f59e0b',\n                        'danger': '#ef4444',\n                    }\n                }\n            }\n        }\n    </script>\n    <link rel=\"stylesheet\" href=\"/static/css/themes.css\">\n    <script src=\"/static/js/themes.js\"></script>\n    <script src=\"/static/js/mobile-menu.js\"></script>\n</head>\n<body class=\"bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200\">\n    <div class=\"flex flex-col min-h-screen\">\n        <!-- Header -->\n        <header class=\"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 transition-colors duration-200\">\n            <div class=\"flex items-center justify-between max-w-7xl mx-auto\">\n                <div class=\"flex items-center space-x-4 md:space-x-8\">\n                    <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                    <!-- Desktop Navigation -->\n                    <nav class=\"hidden md:flex space-x-1\">\n                        <a href=\"/\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                    </nav>\n                    <!-- Mobile Hamburger Button -->\n                    <button id=\"mobile-menu-button\" class=\"md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Open menu\">\n                        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 12h16M4 18h16\"></path>\n                        </svg>\n                    </button>\n                </div>\n                <!-- Desktop Actions -->\n                <div class=\"hidden md:flex items-center space-x-3\">\n                    <button \n                        hx-get=\"/form/subscription\"\n                        hx-target=\"#modal-content\"\n                        hx-trigger=\"click\"\n                        onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                        class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 flex items-center transition-colors duration-150\">\n                        <svg class=\"w-4 h-4 mr-1\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                        </svg>\n                        Add\n                    </button>\n                    <a href=\"/settings\" class=\"text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-150\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n                <!-- Mobile Actions (Settings only, Add is in menu) -->\n                <div class=\"md:hidden flex items-center\">\n                    <a href=\"/settings\" class=\"p-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-150\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n            </div>\n        </header>\n\n        <!-- Mobile Menu Overlay -->\n        <div id=\"mobile-menu\" class=\"hidden fixed inset-0 z-50 md:hidden\">\n            <!-- Backdrop -->\n            <div class=\"fixed inset-0 bg-black bg-opacity-50 transition-opacity\" onclick=\"closeMobileMenu()\"></div>\n            <!-- Menu Panel -->\n            <div class=\"fixed left-0 top-0 bottom-0 w-64 bg-white dark:bg-gray-800 shadow-xl transform transition-transform duration-300 ease-in-out\">\n                <div class=\"flex flex-col h-full\">\n                    <!-- Menu Header -->\n                    <div class=\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700\">\n                        <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                        <button onclick=\"closeMobileMenu()\" class=\"p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Close menu\">\n                            <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                            </svg>\n                        </button>\n                    </div>\n                    <!-- Menu Items -->\n                    <nav class=\"flex-1 overflow-y-auto p-4 space-y-2\">\n                        <a href=\"/\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                        <div class=\"pt-4 border-t border-gray-200 dark:border-gray-700 mt-4\">\n                            <button \n                                onclick=\"closeMobileMenuAndThen(function() { document.getElementById('modal').classList.remove('hidden'); });\"\n                                hx-get=\"/form/subscription\"\n                                hx-target=\"#modal-content\"\n                                hx-trigger=\"click\"\n                                class=\"w-full flex items-center justify-center px-4 py-3 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n                                <svg class=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                                </svg>\n                                Add Subscription\n                            </button>\n                        </div>\n                    </nav>\n                </div>\n            </div>\n        </div>\n\n        <!-- Main Content -->\n        <main class=\"flex-1 p-4\">\n            <div class=\"max-w-7xl mx-auto\">\n                <!-- Calendar Header -->\n                <div class=\"flex items-center justify-between mb-6\">\n                    <div class=\"flex items-center space-x-4\">\n                        <a href=\"/calendar?year={{.PrevMonth.Year}}&month={{printf \"%d\" (int .PrevMonth.Month)}}\" \n                           class=\"p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\"></path>\n                            </svg>\n                        </a>\n                        <h1 class=\"text-2xl font-bold text-gray-900 dark:text-white\">{{.MonthName}}</h1>\n                        <a href=\"/calendar?year={{.NextMonth.Year}}&month={{printf \"%d\" (int .NextMonth.Month)}}\" \n                           class=\"p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\"></path>\n                            </svg>\n                        </a>\n                        <a href=\"/calendar\" class=\"ml-4 px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white\">\n                            Today\n                        </a>\n                    </div>\n                    <div class=\"flex items-center space-x-2\">\n                        {{if .ICalSubscriptionEnabled}}\n                        <button onclick=\"copyCalSubscriptionURL()\" type=\"button\"\n                            class=\"bg-success text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-success/90 flex items-center transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1\"></path>\n                            </svg>\n                            <span id=\"subscribe-btn-text\">Subscribe</span>\n                        </button>\n                        <input type=\"hidden\" id=\"cal-subscription-url\" value=\"{{.ICalSubscriptionURL}}\">\n                        <script>\n                            function copyCalSubscriptionURL() {\n                                var url = document.getElementById('cal-subscription-url').value;\n                                navigator.clipboard.writeText(url).then(function() {\n                                    var span = document.getElementById('subscribe-btn-text');\n                                    span.textContent = 'Copied!';\n                                    setTimeout(function() { span.textContent = 'Subscribe'; }, 2000);\n                                });\n                            }\n                        </script>\n                        {{end}}\n                        <a href=\"/api/export/ical\"\n                           class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 flex items-center transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Export to iCal\n                        </a>\n                    </div>\n                </div>\n\n                <!-- Calendar Grid -->\n                <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden\">\n                    <!-- Day Headers -->\n                    <div class=\"grid grid-cols-7 border-b border-gray-200 dark:border-gray-700\">\n                        <div class=\"px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900\">Sun</div>\n                        <div class=\"px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900\">Mon</div>\n                        <div class=\"px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900\">Tue</div>\n                        <div class=\"px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900\">Wed</div>\n                        <div class=\"px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900\">Thu</div>\n                        <div class=\"px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900\">Fri</div>\n                        <div class=\"px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900\">Sat</div>\n                    </div>\n\n                    <!-- Calendar Days -->\n                    <div class=\"grid grid-cols-7\" id=\"calendar-grid\">\n                        <!-- Days will be populated by JavaScript -->\n                    </div>\n                </div>\n            </div>\n        </main>\n    </div>\n\n    <!-- Modal -->\n    <div id=\"modal\" class=\"hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50\" style=\"z-index: 9999;\">\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4 transition-colors duration-200\" style=\"z-index: 10000;\">\n            <div id=\"modal-content\">\n                <!-- Dynamic content loaded here -->\n            </div>\n        </div>\n    </div>\n\n    <script>\n        // Calendar data from server - parse JSON safely\n        let eventsByDate = {};\n        try {\n            const eventsData = {{.EventsByDate}};\n            if (typeof eventsData === 'string') {\n                eventsByDate = JSON.parse(eventsData);\n            } else if (typeof eventsData === 'object') {\n                eventsByDate = eventsData;\n            }\n        } catch (e) {\n            console.error('Error parsing eventsByDate:', e);\n            eventsByDate = {};\n        }\n        \n        const year = parseInt({{.Year}}) || new Date().getFullYear();\n        const month = parseInt({{.Month}}) || new Date().getMonth() + 1;\n        const currencySymbol = \"{{.CurrencySymbol}}\";\n        \n        console.log('Calendar initialized:', { year, month, eventsCount: Object.keys(eventsByDate).length });\n        \n        // Render calendar grid\n        function renderCalendar() {\n            const grid = document.getElementById('calendar-grid');\n            if (!grid) {\n                console.error('Calendar grid not found');\n                return;\n            }\n            \n            grid.innerHTML = '';\n\n            const firstOfMonth = new Date(year, month - 1, 1);\n            const lastOfMonth = new Date(year, month, 0);\n            const daysInMonth = lastOfMonth.getDate();\n            const startDay = firstOfMonth.getDay(); // 0 = Sunday, 6 = Saturday\n\n            // Previous month days\n            const prevMonth = new Date(year, month - 2, 0);\n            const daysInPrevMonth = prevMonth.getDate();\n            for (let i = startDay - 1; i >= 0; i--) {\n                const day = daysInPrevMonth - i;\n                const cell = document.createElement('div');\n                cell.className = 'min-h-24 p-2 border-r border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50';\n                cell.innerHTML = `<div class=\"text-sm text-gray-400 dark:text-gray-600\">${day}</div>`;\n                grid.appendChild(cell);\n            }\n\n            // Current month days\n            for (let day = 1; day <= daysInMonth; day++) {\n                const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;\n                const events = eventsByDate[dateKey] || [];\n                const cell = document.createElement('div');\n                const hasEvents = events.length > 0;\n                cell.className = `min-h-24 p-2 border-r border-b border-gray-200 dark:border-gray-700 ${hasEvents ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`;\n                \n                let content = `<div class=\"text-sm font-medium text-gray-900 dark:text-white mb-1\">${day}</div>`;\n                if (hasEvents) {\n                    content += '<div class=\"space-y-1\">';\n                    events.forEach(event => {\n                        const eventName = (event.name || '').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n                        const eventId = event.id || 0;\n                        const iconURL = event.icon_url || '';\n                        const hasIcon = iconURL && iconURL.trim() !== '';\n                        \n                        let iconHtml = '';\n                        if (hasIcon) {\n                            const safeIconURL = iconURL.replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n                            iconHtml = `<img src=\"${safeIconURL}\" alt=\"${eventName}\" class=\"w-3 h-3 rounded mr-1.5 flex-shrink-0 inline-block\" style=\"object-fit: contain;\" onerror=\"this.style.display='none';\">`;\n                        }\n                        \n                        const cost = (event.cost || 0).toFixed(2);\n                        content += `<button\n                            hx-get=\"/form/subscription/${eventId}\"\n                            hx-target=\"#modal-content\"\n                            hx-swap=\"innerHTML\"\n                            hx-trigger=\"click\"\n                            class=\"w-full text-left text-xs px-2 py-1 rounded bg-blue-100 dark:bg-gray-700 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-gray-600 transition-colors cursor-pointer flex items-center justify-between\" \n                            title=\"${eventName} - ${currencySymbol}${cost}\"\n                            onclick=\"setTimeout(function() { document.getElementById('modal').classList.remove('hidden'); }, 50);\">\n                            <span class=\"flex items-center min-w-0 flex-1\">\n                                ${iconHtml}<span class=\"truncate\">${eventName}</span>\n                            </span>\n                            <span class=\"ml-2 flex-shrink-0 font-medium\">${currencySymbol}${cost}</span>\n                        </button>`;\n                    });\n                    content += '</div>';\n                }\n                cell.innerHTML = content;\n                grid.appendChild(cell);\n                \n                // Process HTMX attributes on newly created buttons\n                if (typeof htmx !== 'undefined') {\n                    htmx.process(cell);\n                }\n            }\n\n            // Next month days (fill remaining cells)\n            const totalCells = grid.children.length;\n            const remainingCells = 42 - totalCells; // 6 rows * 7 days = 42\n            for (let day = 1; day <= remainingCells; day++) {\n                const cell = document.createElement('div');\n                cell.className = 'min-h-24 p-2 border-r border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50';\n                cell.innerHTML = `<div class=\"text-sm text-gray-400 dark:text-gray-600\">${day}</div>`;\n                grid.appendChild(cell);\n            }\n        }\n\n        // Initialize calendar on page load\n        if (document.readyState === 'loading') {\n            document.addEventListener('DOMContentLoaded', renderCalendar);\n        } else {\n            renderCalendar();\n        }\n\n        // Mobile menu functionality is handled by /static/js/mobile-menu.js\n\n        // Close modal when clicking outside\n        const modal = document.getElementById('modal');\n        if (modal) {\n            modal.addEventListener('click', function(e) {\n                if (e.target === this) {\n                    this.classList.add('hidden');\n                }\n            });\n        }\n        \n        // Listen for HTMX afterSwap to show modal when content loads\n        if (typeof htmx !== 'undefined') {\n            document.body.addEventListener('htmx:afterSwap', function(evt) {\n                if (evt.detail.target.id === 'modal-content' && evt.detail.target.innerHTML.trim() !== '') {\n                    const modal = document.getElementById('modal');\n                    if (modal) {\n                        modal.classList.remove('hidden');\n                    }\n                }\n            });\n        }\n    </script>\n</body>\n</html>\n\n"
  },
  {
    "path": "templates/categories-list.html",
    "content": "{{if .}}\n    {{range .}}\n        <div class=\"flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg\">\n            <div class=\"flex-1\">\n                <span class=\"category-name text-sm font-medium text-gray-900\" id=\"category-name-{{.ID}}\">{{.Name}}</span>\n                <form id=\"edit-category-form-{{.ID}}\" class=\"hidden inline\" hx-put=\"/api/categories/{{.ID}}\" hx-target=\"#categories-list\" hx-swap=\"innerHTML\">\n                    <input type=\"text\" name=\"name\" value=\"{{.Name}}\" class=\"px-2 py-1 border border-gray-300 rounded text-sm\">\n                    <button type=\"submit\" class=\"text-primary text-sm font-medium ml-2\">Save</button>\n                    <button type=\"button\" onclick=\"cancelEditCategory({{.ID}})\" class=\"text-gray-500 text-sm ml-1\">Cancel</button>\n                </form>\n            </div>\n            <div class=\"flex items-center space-x-2\">\n                <button id=\"edit-btn-{{.ID}}\" onclick=\"startEditCategory({{.ID}})\" class=\"text-blue-600 hover:text-blue-800 text-sm font-medium\">Edit</button>\n                <button hx-delete=\"/api/categories/{{.ID}}\" hx-confirm=\"Delete this category?\" hx-target=\"#categories-list\" hx-swap=\"innerHTML\" class=\"text-red-600 hover:text-red-800 text-sm font-medium\">Delete</button>\n            </div>\n        </div>\n    {{end}}\n{{else}}\n    <div class=\"text-center py-4 text-gray-500\">No categories found.</div>\n{{end}} \n<script src=\"/static/category-management.js\"></script> "
  },
  {
    "path": "templates/dashboard.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"theme-color\" content=\"#37889b\">\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/manifest.json\">\n    <title>{{.Title}} - SubTrackr</title>\n    <script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script>\n        tailwind.config = {\n            darkMode: 'class',\n            theme: {\n                extend: {\n                    colors: {\n                        'primary': '#3b82f6',\n                        'success': '#10b981',\n                        'warning': '#f59e0b',\n                        'danger': '#ef4444',\n                    }\n                }\n            }\n        }\n    </script>\n    <link rel=\"stylesheet\" href=\"/static/css/themes.css\">\n    <script src=\"/static/js/themes.js\"></script>\n    <script src=\"/static/js/mobile-menu.js\"></script>\n</head>\n<body class=\"bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200\">\n    <div class=\"flex flex-col min-h-screen\">\n        <!-- Header -->\n        <header class=\"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 transition-colors duration-200\">\n            <div class=\"flex items-center justify-between max-w-7xl mx-auto\">\n                <div class=\"flex items-center space-x-4 md:space-x-8\">\n                    <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                    <!-- Desktop Navigation -->\n                    <nav class=\"hidden md:flex space-x-1\">\n                        <a href=\"/\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                    </nav>\n                    <!-- Mobile Hamburger Button -->\n                    <button id=\"mobile-menu-button\" class=\"md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Open menu\">\n                        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 12h16M4 18h16\"></path>\n                        </svg>\n                    </button>\n                </div>\n                <!-- Desktop Actions -->\n                <div class=\"hidden md:flex items-center space-x-3\">\n                    <button \n                        hx-get=\"/form/subscription\"\n                        hx-target=\"#modal-content\"\n                        hx-trigger=\"click\"\n                        onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                        class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 flex items-center transition-colors duration-150\">\n                        <svg class=\"w-4 h-4 mr-1\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                        </svg>\n                        Add\n                    </button>\n                    <a href=\"/settings\" class=\"text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-150\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n                <!-- Mobile Actions (Settings only, Add is in menu) -->\n                <div class=\"md:hidden flex items-center\">\n                    <a href=\"/settings\" class=\"p-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-150\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n            </div>\n        </header>\n\n        <!-- Mobile Menu Overlay -->\n        <div id=\"mobile-menu\" class=\"hidden fixed inset-0 z-50 md:hidden\">\n            <!-- Backdrop -->\n            <div class=\"fixed inset-0 bg-black bg-opacity-50 transition-opacity\" onclick=\"closeMobileMenu()\"></div>\n            <!-- Menu Panel -->\n            <div class=\"fixed left-0 top-0 bottom-0 w-64 bg-white dark:bg-gray-800 shadow-xl transform transition-transform duration-300 ease-in-out\">\n                <div class=\"flex flex-col h-full\">\n                    <!-- Menu Header -->\n                    <div class=\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700\">\n                        <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                        <button onclick=\"closeMobileMenu()\" class=\"p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Close menu\">\n                            <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                            </svg>\n                        </button>\n                    </div>\n                    <!-- Menu Items -->\n                    <nav class=\"flex-1 overflow-y-auto p-4 space-y-2\">\n                        <a href=\"/\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                        <div class=\"pt-4 border-t border-gray-200 dark:border-gray-700 mt-4\">\n                            <button \n                                onclick=\"closeMobileMenuAndThen(function() { document.getElementById('modal').classList.remove('hidden'); });\"\n                                hx-get=\"/form/subscription\"\n                                hx-target=\"#modal-content\"\n                                hx-trigger=\"click\"\n                                class=\"w-full flex items-center justify-center px-4 py-3 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n                                <svg class=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                                </svg>\n                                Add Subscription\n                            </button>\n                        </div>\n                    </nav>\n                </div>\n            </div>\n        </div>\n\n        <!-- Main Content -->\n        <main class=\"flex-1 p-4\">\n            <div class=\"max-w-7xl mx-auto\">\n<!-- Stats Cards -->\n<div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8\">\n    <!-- Monthly Spend -->\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <div class=\"flex items-center justify-between\">\n            <div>\n                <p class=\"text-sm font-medium text-gray-600 dark:text-gray-300\">Monthly Spend</p>\n                <p class=\"text-3xl font-bold text-primary\">{{.CurrencySymbol}}{{printf \"%.2f\" .Stats.TotalMonthlySpend}}</p>\n            </div>\n            <div class=\"w-12 h-12 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center\">\n                <svg class=\"w-6 h-6 text-primary\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                    <path d=\"M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z\"></path>\n                    <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z\" clip-rule=\"evenodd\"></path>\n                </svg>\n            </div>\n        </div>\n    </div>\n\n    <!-- Annual Spend -->\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <div class=\"flex items-center justify-between\">\n            <div>\n                <p class=\"text-sm font-medium text-gray-600 dark:text-gray-300\">Annual Spend</p>\n                <p class=\"text-3xl font-bold text-success\">{{.CurrencySymbol}}{{printf \"%.2f\" .Stats.TotalAnnualSpend}}</p>\n            </div>\n            <div class=\"w-12 h-12 bg-green-100 dark:bg-green-900/50 rounded-full flex items-center justify-center\">\n                <svg class=\"w-6 h-6 text-success\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 7h8m0 0v8m0-8l-8 8-4-4-6 6\"></path>\n                </svg>\n            </div>\n        </div>\n    </div>\n\n    <!-- Active Subscriptions -->\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <div class=\"flex items-center justify-between\">\n            <div>\n                <p class=\"text-sm font-medium text-gray-600 dark:text-gray-300\">Active Subscriptions</p>\n                <p class=\"text-3xl font-bold text-warning\">{{.Stats.ActiveSubscriptions}}</p>\n            </div>\n            <div class=\"w-12 h-12 bg-yellow-100 dark:bg-yellow-900/50 rounded-full flex items-center justify-center\">\n                <svg class=\"w-6 h-6 text-warning\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                </svg>\n            </div>\n        </div>\n    </div>\n\n    <!-- Monthly Savings -->\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <div class=\"flex items-center justify-between\">\n            <div>\n                <p class=\"text-sm font-medium text-gray-600 dark:text-gray-300\">Monthly Savings</p>\n                <p class=\"text-3xl font-bold text-danger\">{{.CurrencySymbol}}{{printf \"%.2f\" .Stats.MonthlySaved}}</p>\n                <p class=\"text-xs text-gray-500 dark:text-gray-400\">From cancellations</p>\n            </div>\n            <div class=\"w-12 h-12 bg-red-100 dark:bg-red-900/50 rounded-full flex items-center justify-center\">\n                <svg class=\"w-6 h-6 text-danger\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                    <path d=\"M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z\"></path>\n                    <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z\" clip-rule=\"evenodd\"></path>\n                </svg>\n            </div>\n        </div>\n    </div>\n</div>\n\n<!-- Spending by Category -->\n<div class=\"bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700 mb-8 transition-colors duration-200\">\n    <h2 class=\"text-lg font-semibold text-gray-900 dark:text-white mb-6\">Spending by Category</h2>\n    \n    \n    <div class=\"space-y-4\">\n        {{range $category, $amount := .Stats.CategorySpending}}\n        <div class=\"flex items-center justify-between\">\n            <div class=\"flex items-center\">\n                <div class=\"w-3 h-3 bg-primary rounded-full mr-3\"></div>\n                <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">{{$category}}</span>\n            </div>\n            <div class=\"flex items-center space-x-4\">\n                <div class=\"w-32 rounded-full h-2 overflow-hidden\" style=\"background-color: #e5e7eb;\">\n                    <div class=\"h-2 rounded-full transition-all duration-300\" \n                         style=\"width: {{printf \"%.0f\" (div (mul $amount 100.0) $.Stats.TotalMonthlySpend)}}%; background-color: #3b82f6;\"></div>\n                </div>\n                <span class=\"text-sm font-medium text-gray-900 dark:text-white w-16 text-right\">{{$.CurrencySymbol}}{{printf \"%.2f\" $amount}}</span>\n            </div>\n        </div>\n        {{else}}\n        <div class=\"text-center py-8 text-gray-500 dark:text-gray-400\">\n            <p>No category spending data found.</p>\n            <p class=\"text-xs mt-2\">Add some active subscriptions to see category breakdown.</p>\n        </div>\n        {{end}}\n    </div>\n</div>\n\n<!-- All Subscriptions -->\n<div class=\"bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n    <div class=\"p-6 border-b border-gray-200 dark:border-gray-700\">\n        <div class=\"flex items-center justify-between\">\n            <h2 class=\"text-lg font-semibold text-gray-900 dark:text-white\">All Subscriptions</h2>\n        </div>\n    </div>\n    <div class=\"divide-y divide-gray-200 dark:divide-gray-700\">\n        {{range .Subscriptions}}\n        <div class=\"p-6 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150\">\n            <div class=\"flex items-center\">\n                {{if .IconURL}}\n                <img src=\"{{.IconURL}}\" alt=\"{{.Name}}\" class=\"w-8 h-8 rounded mr-3 flex-shrink-0\" onerror=\"this.style.display='none'; this.nextElementSibling.style.display='block';\" style=\"object-fit: contain;\">\n                <div class=\"w-3 h-3 {{if eq .Status \"Active\"}}bg-success{{else}}bg-gray-400{{end}} rounded-full mr-4\" style=\"display:none;\"></div>\n                {{else}}\n                <div class=\"w-3 h-3 {{if eq .Status \"Active\"}}bg-success{{else}}bg-gray-400{{end}} rounded-full mr-4\"></div>\n                {{end}}\n                <div>\n                    <h3 class=\"text-sm font-medium text-gray-900 dark:text-white\">{{.Name}}</h3>\n                    <p class=\"text-sm text-gray-500 dark:text-gray-400\">{{.Category.Name}} • {{.Status}}</p>\n                </div>\n            </div>\n            <div class=\"text-right\">\n                {{if .ShowConversion}}\n                <p class=\"text-sm font-medium text-gray-900 dark:text-white\">{{.DisplayCurrencySymbol}}{{printf \"%.2f\" .ConvertedCost}}</p>\n                <a href=\"https://fixer.io\" target=\"_blank\" rel=\"noopener\"\n                   class=\"text-xs text-gray-500 dark:text-gray-400 hover:text-primary inline-flex items-center gap-1\"\n                   title=\"Original amount before conversion (rates from Fixer.io)\">\n                    {{.OriginalCurrency}} {{printf \"%.2f\" .Cost}}\n                    <svg class=\"w-3 h-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                    </svg>\n                </a>\n                {{else}}\n                <p class=\"text-sm font-medium text-gray-900 dark:text-white\">{{.DisplayCurrencySymbol}}{{printf \"%.2f\" .Cost}}</p>\n                {{end}}\n                <p class=\"text-sm text-gray-500 dark:text-gray-400\">{{.DisplaySchedule}}</p>\n            </div>\n        </div>\n        {{end}}\n    </div>\n</div>\n            </div>\n        </main>\n    </div>\n\n    <!-- Modal -->\n    <div id=\"modal\" class=\"hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50\">\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4 transition-colors duration-200\">\n            <div id=\"modal-content\">\n                <!-- Dynamic content loaded here -->\n            </div>\n        </div>\n    </div>\n\n    <script>\n        // Close modal when clicking outside\n        document.getElementById('modal').addEventListener('click', function(e) {\n            if (e.target === this) {\n                this.classList.add('hidden');\n            }\n        });\n    </script>\n</body>\n</html>"
  },
  {
    "path": "templates/error.html",
    "content": "{{define \"content\"}}\n<div class=\"flex items-center justify-center min-h-[400px]\">\n    <div class=\"text-center\">\n        <svg class=\"w-16 h-16 text-red-400 mx-auto mb-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.732 16.5c-.77.833.192 2.5 1.732 2.5z\"></path>\n        </svg>\n        <h2 class=\"text-xl font-semibold text-gray-900 dark:text-white mb-2\">Something went wrong</h2>\n        <p class=\"text-gray-600 dark:text-gray-300 mb-4\">{{.error}}</p>\n        <a href=\"/\" class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n            Back to Dashboard\n        </a>\n    </div>\n</div>\n{{end}}"
  },
  {
    "path": "templates/forgot-password-error.html",
    "content": "<div class=\"bg-red-50 border border-red-200 rounded-lg p-3 mb-4\">\n    <p class=\"text-sm text-red-700\">{{.Error}}</p>\n</div>\n<div class=\"text-center\">\n    <a href=\"/login\" class=\"text-sm text-gray-600 hover:text-gray-700\">Back to Login</a>\n</div>\n"
  },
  {
    "path": "templates/forgot-password-success.html",
    "content": "<div class=\"bg-green-50 border border-green-200 rounded-lg p-4 mb-4\">\n    <p class=\"text-sm text-green-700\">{{.Message}}</p>\n    <p class=\"text-xs text-gray-600 mt-2\">Please check your email for the password reset link</p>\n</div>\n<div class=\"text-center\">\n    <a href=\"/login\" class=\"text-sm text-blue-600 hover:text-blue-700\">Back to Login</a>\n</div>\n"
  },
  {
    "path": "templates/forgot-password.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"theme-color\" content=\"#37889b\">\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/manifest.json\">\n    <title>Forgot Password - SubTrackr</title>\n    <script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <link rel=\"stylesheet\" href=\"/static/css/themes.css\">\n    <script src=\"/static/js/themes.js\"></script>\n</head>\n<body class=\"bg-gray-50 min-h-screen flex items-center justify-center\">\n    <div class=\"max-w-md w-full mx-auto p-6\">\n        <div class=\"text-center mb-8\">\n            <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-12 w-auto mx-auto mb-4\">\n            <h2 class=\"text-2xl font-bold text-gray-900\">Reset Your Password</h2>\n            <p class=\"text-gray-600 mt-2\">We'll send a reset link to your configured email address</p>\n        </div>\n\n        <div class=\"bg-white rounded-lg shadow-sm border border-gray-200 p-6\">\n            <div id=\"forgot-password-content\">\n                <form hx-post=\"/api/auth/forgot-password\" hx-target=\"#forgot-password-content\" hx-swap=\"innerHTML\">\n                    <button type=\"submit\" class=\"w-full bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\">\n                        Send Reset Link\n                    </button>\n                </form>\n\n                <div class=\"text-center mt-4\">\n                    <a href=\"/login\" class=\"text-sm text-gray-600 hover:text-gray-700\">Back to Login</a>\n                </div>\n            </div>\n        </div>\n    </div>\n</body>\n</html>\n"
  },
  {
    "path": "templates/form-errors.html",
    "content": "<div class=\"bg-red-50 border border-red-200 rounded-lg p-4 mb-4\">\n    <div class=\"flex\">\n        <svg class=\"w-5 h-5 text-red-400 mr-2\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n            <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z\" clip-rule=\"evenodd\"></path>\n        </svg>\n        <div>\n            <h3 class=\"text-sm font-medium text-red-800\">Error</h3>\n            <p class=\"text-sm text-red-700 mt-1\">{{.Error}}</p>\n        </div>\n    </div>\n</div>"
  },
  {
    "path": "templates/login-error.html",
    "content": "<div class=\"bg-red-50 border border-red-200 rounded-lg p-3\">\n    <p class=\"text-sm text-red-700\">{{.Error}}</p>\n</div>\n"
  },
  {
    "path": "templates/login.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"theme-color\" content=\"#37889b\">\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/manifest.json\">\n    <title>Login - SubTrackr</title>\n    <script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <link rel=\"stylesheet\" href=\"/static/css/themes.css\">\n    <script src=\"/static/js/themes.js\"></script>\n</head>\n<body class=\"bg-gray-50 min-h-screen flex items-center justify-center\">\n    <div class=\"max-w-md w-full mx-auto p-6\">\n        <div class=\"text-center mb-8\">\n            <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-12 w-auto mx-auto mb-4\">\n            <h2 class=\"text-2xl font-bold text-gray-900\">Sign in to SubTrackr</h2>\n        </div>\n\n        <div class=\"bg-white rounded-lg shadow-sm border border-gray-200 p-6\">\n            <form hx-post=\"/api/auth/login\" hx-target=\"#login-error\" hx-swap=\"innerHTML\">\n                <input type=\"hidden\" name=\"redirect\" value=\"{{.Redirect}}\">\n                \n                <div class=\"space-y-4\">\n                    <div>\n                        <label for=\"username\" class=\"block text-sm font-medium text-gray-700 mb-1\">Username</label>\n                        <input type=\"text\" id=\"username\" name=\"username\" required autofocus\n                               class=\"w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                    </div>\n\n                    <div>\n                        <label for=\"password\" class=\"block text-sm font-medium text-gray-700 mb-1\">Password</label>\n                        <input type=\"password\" id=\"password\" name=\"password\" required\n                               class=\"w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                    </div>\n\n                    <div class=\"flex items-center\">\n                        <input type=\"checkbox\" id=\"remember_me\" name=\"remember_me\" class=\"h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded\">\n                        <label for=\"remember_me\" class=\"ml-2 block text-sm text-gray-700\">Remember me for 30 days</label>\n                    </div>\n\n                    <div id=\"login-error\" class=\"min-h-[20px]\"></div>\n\n                    <button type=\"submit\" class=\"w-full bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\">\n                        Sign In\n                    </button>\n\n                    <div class=\"text-center mt-4\">\n                        <a href=\"/forgot-password\" class=\"text-sm text-blue-600 hover:text-blue-700\">Forgot your password?</a>\n                    </div>\n                </div>\n            </form>\n        </div>\n    </div>\n</body>\n</html>\n"
  },
  {
    "path": "templates/reset-password-error.html",
    "content": "<div class=\"bg-red-50 border border-red-200 rounded-lg p-3 mb-4\">\n    <p class=\"text-sm text-red-700\">{{.Error}}</p>\n</div>\n<div class=\"text-center\">\n    <a href=\"/forgot-password\" class=\"text-sm text-blue-600 hover:text-blue-700\">Request a new reset link</a>\n</div>\n"
  },
  {
    "path": "templates/reset-password-success.html",
    "content": "<div class=\"bg-green-50 border border-green-200 rounded-lg p-4 mb-4\">\n    <p class=\"text-sm text-green-700\">{{.Message}}</p>\n</div>\n<div class=\"text-center\">\n    <a href=\"/login\" class=\"inline-block bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700\">\n        Go to Login\n    </a>\n</div>\n"
  },
  {
    "path": "templates/reset-password.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"theme-color\" content=\"#37889b\">\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/manifest.json\">\n    <title>Reset Password - SubTrackr</title>\n    <script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <link rel=\"stylesheet\" href=\"/static/css/themes.css\">\n    <script src=\"/static/js/themes.js\"></script>\n</head>\n<body class=\"bg-gray-50 min-h-screen flex items-center justify-center\">\n    <div class=\"max-w-md w-full mx-auto p-6\">\n        <div class=\"text-center mb-8\">\n            <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-12 w-auto mx-auto mb-4\">\n            <h2 class=\"text-2xl font-bold text-gray-900\">Set New Password</h2>\n        </div>\n\n        <div class=\"bg-white rounded-lg shadow-sm border border-gray-200 p-6\">\n            <div id=\"reset-password-content\">\n                {{if .Error}}\n                <div class=\"bg-red-50 border border-red-200 rounded-lg p-3 mb-4\">\n                    <p class=\"text-sm text-red-700\">{{.Error}}</p>\n                </div>\n                <div class=\"text-center\">\n                    <a href=\"/forgot-password\" class=\"text-sm text-blue-600 hover:text-blue-700\">Request a new reset link</a>\n                </div>\n                {{else}}\n                <form hx-post=\"/api/auth/reset-password\" hx-target=\"#reset-password-content\" hx-swap=\"innerHTML\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{.Token}}\">\n                    \n                    <div class=\"space-y-4\">\n                        <div>\n                            <label for=\"new_password\" class=\"block text-sm font-medium text-gray-700 mb-1\">New Password</label>\n                            <input type=\"password\" id=\"new_password\" name=\"new_password\" required minlength=\"8\" autofocus\n                                   class=\"w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                            <p class=\"text-xs text-gray-500 mt-1\">Minimum 8 characters</p>\n                        </div>\n\n                        <div>\n                            <label for=\"confirm_password\" class=\"block text-sm font-medium text-gray-700 mb-1\">Confirm Password</label>\n                            <input type=\"password\" id=\"confirm_password\" name=\"confirm_password\" required minlength=\"8\"\n                                   class=\"w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                        </div>\n\n                        <button type=\"submit\" class=\"w-full bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\">\n                            Reset Password\n                        </button>\n                    </div>\n                </form>\n                {{end}}\n            </div>\n        </div>\n    </div>\n</body>\n</html>\n"
  },
  {
    "path": "templates/settings.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"theme-color\" content=\"#37889b\">\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/manifest.json\">\n    <title>{{.Title}} - SubTrackr</title>\n    <script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script>\n        tailwind.config = {\n            darkMode: 'class',\n            theme: {\n                extend: {\n                    colors: {\n                        'primary': '#3b82f6',\n                        'success': '#10b981',\n                        'warning': '#f59e0b',\n                        'danger': '#ef4444',\n                    }\n                }\n            }\n        }\n\n    </script>\n    <link rel=\"stylesheet\" href=\"/static/css/themes.css\">\n    <script src=\"/static/js/themes.js\"></script>\n    <script src=\"/static/js/mobile-menu.js\"></script>\n</head>\n<body class=\"bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200\">\n    <div class=\"flex flex-col min-h-screen\">\n        <!-- Header -->\n        <header class=\"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 transition-colors duration-200\">\n            <div class=\"flex items-center justify-between max-w-7xl mx-auto\">\n                <div class=\"flex items-center space-x-4 md:space-x-8\">\n                    <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                    <!-- Desktop Navigation -->\n                    <nav class=\"hidden md:flex space-x-1\">\n                        <a href=\"/\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                    </nav>\n                    <!-- Mobile Hamburger Button -->\n                    <button id=\"mobile-menu-button\" class=\"md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Open menu\">\n                        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 12h16M4 18h16\"></path>\n                        </svg>\n                    </button>\n                </div>\n                <!-- Desktop Actions -->\n                <div class=\"hidden md:flex items-center space-x-3\">\n                    <button \n                        hx-get=\"/form/subscription\"\n                        hx-target=\"#modal-content\"\n                        hx-trigger=\"click\"\n                        onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                        class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 flex items-center transition-colors duration-150\">\n                        <svg class=\"w-4 h-4 mr-1\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                        </svg>\n                        Add\n                    </button>\n                    <a href=\"/settings\" class=\"text-primary dark:text-primary-light\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n                <!-- Mobile Actions (Settings only, Add is in menu) -->\n                <div class=\"md:hidden flex items-center\">\n                    <a href=\"/settings\" class=\"p-2 text-primary dark:text-primary-light\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n            </div>\n        </header>\n\n        <!-- Mobile Menu Overlay -->\n        <div id=\"mobile-menu\" class=\"hidden fixed inset-0 z-50 md:hidden\">\n            <!-- Backdrop -->\n            <div class=\"fixed inset-0 bg-black bg-opacity-50 transition-opacity\" onclick=\"closeMobileMenu()\"></div>\n            <!-- Menu Panel -->\n            <div class=\"fixed left-0 top-0 bottom-0 w-64 bg-white dark:bg-gray-800 shadow-xl transform transition-transform duration-300 ease-in-out\">\n                <div class=\"flex flex-col h-full\">\n                    <!-- Menu Header -->\n                    <div class=\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700\">\n                        <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                        <button onclick=\"closeMobileMenu()\" class=\"p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Close menu\">\n                            <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                            </svg>\n                        </button>\n                    </div>\n                    <!-- Menu Items -->\n                    <nav class=\"flex-1 overflow-y-auto p-4 space-y-2\">\n                        <a href=\"/\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                        <div class=\"pt-4 border-t border-gray-200 dark:border-gray-700 mt-4\">\n                            <button \n                                onclick=\"closeMobileMenuAndThen(function() { document.getElementById('modal').classList.remove('hidden'); });\"\n                                hx-get=\"/form/subscription\"\n                                hx-target=\"#modal-content\"\n                                hx-trigger=\"click\"\n                                class=\"w-full flex items-center justify-center px-4 py-3 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n                                <svg class=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                                </svg>\n                                Add Subscription\n                            </button>\n                        </div>\n                    </nav>\n                </div>\n            </div>\n        </div>\n\n        <!-- Main Content -->\n        <main class=\"flex-1 p-4\">\n            <div class=\"max-w-7xl mx-auto\">\n\n<div class=\"max-w-4xl mx-auto\">\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n        <div class=\"p-6 border-b border-gray-200 dark:border-gray-700\">\n            <h2 class=\"text-lg font-semibold text-gray-900 dark:text-white\">Settings</h2>\n            <p class=\"text-sm text-gray-600 dark:text-gray-300 mt-1\">Manage your SubTrackr preferences and data</p>\n        </div>\n        \n        <div class=\"p-6 space-y-8\">\n            <!-- Appearance -->\n            <div>\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Appearance</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Personalize your SubTrackr experience with beautiful themes</p>\n\n                <div class=\"theme-selector\" id=\"theme-selector\">\n                    <div class=\"theme-option\" data-theme=\"default\" onclick=\"selectTheme('default')\">\n                        <div class=\"theme-name\">Default</div>\n                        <div class=\"theme-description\">Clean and professional</div>\n                        <div class=\"theme-preview\">\n                            <div class=\"theme-preview-color\" style=\"background: #3b82f6;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #64748b;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #10b981;\"></div>\n                        </div>\n                    </div>\n\n                    <div class=\"theme-option\" data-theme=\"dark\" onclick=\"selectTheme('dark')\">\n                        <div class=\"theme-name\">Dark</div>\n                        <div class=\"theme-description\">Easy on the eyes</div>\n                        <div class=\"theme-preview\">\n                            <div class=\"theme-preview-color\" style=\"background: #1f2937;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #3b82f6;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #10b981;\"></div>\n                        </div>\n                    </div>\n\n                    <div class=\"theme-option\" data-theme=\"dark-classic\" onclick=\"selectTheme('dark-classic')\">\n                        <div class=\"theme-name\">Dark Classic</div>\n                        <div class=\"theme-description\">Original dark mode</div>\n                        <div class=\"theme-preview\">\n                            <div class=\"theme-preview-color\" style=\"background: #1f2937;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #3b82f6;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #10b981;\"></div>\n                        </div>\n                    </div>\n\n                    <div class=\"theme-option\" data-theme=\"christmas\" onclick=\"selectTheme('christmas')\">\n                        <div class=\"theme-name\">Christmas 🎄</div>\n                        <div class=\"theme-description\">Festive and jolly!</div>\n                        <div class=\"theme-preview\">\n                            <div class=\"theme-preview-color\" style=\"background: #c41e3a;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #165b33;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #ffd700;\"></div>\n                        </div>\n                    </div>\n\n                    <div class=\"theme-option\" data-theme=\"midnight\" onclick=\"selectTheme('midnight')\">\n                        <div class=\"theme-name\">Midnight</div>\n                        <div class=\"theme-description\">Deep and mysterious</div>\n                        <div class=\"theme-preview\">\n                            <div class=\"theme-preview-color\" style=\"background: #0f172a;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #8b5cf6;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #1e293b;\"></div>\n                        </div>\n                    </div>\n\n                    <div class=\"theme-option\" data-theme=\"ocean\" onclick=\"selectTheme('ocean')\">\n                        <div class=\"theme-name\">Ocean</div>\n                        <div class=\"theme-description\">Cool and refreshing</div>\n                        <div class=\"theme-preview\">\n                            <div class=\"theme-preview-color\" style=\"background: #0891b2;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #f0f9ff;\"></div>\n                            <div class=\"theme-preview-color\" style=\"background: #06b6d4;\"></div>\n                        </div>\n                    </div>\n                </div>\n\n                <script>\n                    function selectTheme(themeName) {\n                        // Remove active class from all options\n                        document.querySelectorAll('.theme-option').forEach(opt => {\n                            opt.classList.remove('active');\n                        });\n\n                        // Add active class to selected theme\n                        document.querySelector(`[data-theme=\"${themeName}\"]`).classList.add('active');\n\n                        // Apply theme\n                        applyTheme(themeName);\n                    }\n\n                    // Load and mark current theme as active on page load\n                    document.addEventListener('DOMContentLoaded', () => {\n                        fetch('/api/settings/theme')\n                            .then(response => response.json())\n                            .then(data => {\n                                const themeName = data.theme || 'default';\n                                document.querySelector(`[data-theme=\"${themeName}\"]`)?.classList.add('active');\n                            })\n                            .catch(err => console.error('Failed to load theme:', err));\n                    });\n                </script>\n            </div>\n\n            <!-- Export Data -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Export Data</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Download your subscription data in various formats</p>\n                <div class=\"flex space-x-3\">\n                    <a href=\"/api/export/csv\" class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 inline-block transition-colors duration-150\">\n                        Export as CSV\n                    </a>\n                    <a href=\"/api/export/json\" class=\"bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-600 inline-block transition-colors duration-150\">\n                        Export as JSON\n                    </a>\n                </div>\n            </div>\n            \n            <!-- Base URL -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Base URL</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Set the external URL for your SubTrackr instance. Used for iCal subscription links and password reset emails. Leave blank to auto-detect from request headers.</p>\n                <form hx-post=\"/api/settings/base-url\" hx-swap=\"none\" class=\"flex items-center space-x-2\">\n                    <input type=\"url\" name=\"base_url\" id=\"base-url-input\"\n                        value=\"{{.BaseURL}}\"\n                        placeholder=\"https://subtrackr.example.com\"\n                        class=\"flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-900 dark:text-gray-100\">\n                    <button type=\"submit\"\n                        class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors duration-150\">\n                        Save\n                    </button>\n                </form>\n                <script>\n                    document.body.addEventListener('htmx:afterRequest', function(event) {\n                        if (event.detail.pathInfo.requestPath === '/api/settings/base-url' && event.detail.successful) {\n                            var btn = event.detail.elt.querySelector('button[type=\"submit\"]');\n                            if (btn) {\n                                var original = btn.textContent;\n                                btn.textContent = 'Saved!';\n                                setTimeout(function() { btn.textContent = original; }, 2000);\n                            }\n                        }\n                    });\n                </script>\n            </div>\n\n            <!-- Calendar Subscription -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Calendar Subscription</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Enable a subscribable iCal URL that calendar apps can poll for live updates</p>\n\n                <div class=\"space-y-4\">\n                    <!-- Toggle -->\n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-900 dark:text-white\">iCal Subscription</h4>\n                            <p class=\"text-sm text-gray-500 dark:text-gray-400\">Allow calendar apps to subscribe to your renewal dates</p>\n                        </div>\n                        <button id=\"ical-toggle-btn\"\n                            hx-post=\"/api/settings/ical/toggle\"\n                            hx-swap=\"none\"\n                            class=\"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 {{if .ICalSubscriptionEnabled}}bg-primary{{else}}bg-gray-200 dark:bg-gray-600{{end}}\"\n                            role=\"switch\"\n                            aria-checked=\"{{if .ICalSubscriptionEnabled}}true{{else}}false{{end}}\">\n                            <span class=\"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {{if .ICalSubscriptionEnabled}}translate-x-5{{else}}translate-x-0{{end}}\"></span>\n                        </button>\n                    </div>\n\n                    <!-- URL Display (shown when enabled) -->\n                    <div id=\"ical-url-section\" class=\"{{if not .ICalSubscriptionEnabled}}hidden{{end}}\">\n                        <div class=\"flex items-center space-x-2\">\n                            <input type=\"text\" id=\"ical-url-input\" readonly\n                                value=\"{{.ICalSubscriptionURL}}\"\n                                class=\"flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-900 dark:text-gray-100 font-mono\">\n                            <button onclick=\"copyICalURL()\" type=\"button\"\n                                class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors duration-150\">\n                                Copy\n                            </button>\n                            <button\n                                hx-post=\"/api/settings/ical/regenerate\"\n                                hx-swap=\"none\"\n                                hx-confirm=\"Are you sure? Your old subscription URL will stop working and calendar apps will need the new URL.\"\n                                type=\"button\"\n                                class=\"bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-150\">\n                                Regenerate\n                            </button>\n                        </div>\n                        <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">Add this URL as a \"Subscribe to Calendar\" in your calendar app (Google Calendar, Apple Calendar, Outlook, etc.)</p>\n                    </div>\n                </div>\n\n                <script>\n                    document.body.addEventListener('htmx:afterRequest', function(event) {\n                        if (event.detail.pathInfo.requestPath === '/api/settings/ical/toggle') {\n                            try {\n                                var data = JSON.parse(event.detail.xhr.responseText);\n                                var btn = document.getElementById('ical-toggle-btn');\n                                var section = document.getElementById('ical-url-section');\n                                var input = document.getElementById('ical-url-input');\n                                var knob = btn.querySelector('span');\n\n                                if (data.enabled) {\n                                    btn.classList.remove('bg-gray-200', 'dark:bg-gray-600');\n                                    btn.classList.add('bg-primary');\n                                    btn.setAttribute('aria-checked', 'true');\n                                    knob.classList.remove('translate-x-0');\n                                    knob.classList.add('translate-x-5');\n                                    section.classList.remove('hidden');\n                                    if (data.url) input.value = data.url;\n                                } else {\n                                    btn.classList.remove('bg-primary');\n                                    btn.classList.add('bg-gray-200', 'dark:bg-gray-600');\n                                    btn.setAttribute('aria-checked', 'false');\n                                    knob.classList.remove('translate-x-5');\n                                    knob.classList.add('translate-x-0');\n                                    section.classList.add('hidden');\n                                }\n                            } catch(e) {}\n                        }\n                        if (event.detail.pathInfo.requestPath === '/api/settings/ical/regenerate') {\n                            try {\n                                var data = JSON.parse(event.detail.xhr.responseText);\n                                if (data.url) {\n                                    document.getElementById('ical-url-input').value = data.url;\n                                }\n                            } catch(e) {}\n                        }\n                    });\n\n                    function copyICalURL() {\n                        var input = document.getElementById('ical-url-input');\n                        navigator.clipboard.writeText(input.value).then(function() {\n                            var btn = input.nextElementSibling;\n                            var original = btn.textContent;\n                            btn.textContent = 'Copied!';\n                            setTimeout(function() { btn.textContent = original; }, 2000);\n                        });\n                    }\n                </script>\n            </div>\n\n            <!-- Data Management -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Data Management</h3>\n                <div class=\"space-y-4\">\n                    <div class=\"flex items-center justify-between p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-yellow-900 dark:text-yellow-200\">Backup Data</h4>\n                            <p class=\"text-sm text-yellow-800 dark:text-yellow-300\">Create a backup of all your subscription data</p>\n                        </div>\n                        <a href=\"/api/backup\" class=\"bg-yellow-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-yellow-700 inline-block\">\n                            Create Backup\n                        </a>\n                    </div>\n                    \n                    <div class=\"p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg\">\n                        <div class=\"flex items-center justify-between mb-3\">\n                            <div>\n                                <h4 class=\"text-sm font-medium text-blue-900 dark:text-blue-200\">Restore Backup</h4>\n                                <p class=\"text-sm text-blue-800 dark:text-blue-300\">Import subscriptions from a backup file</p>\n                            </div>\n                        </div>\n                        <form id=\"restore-form\" enctype=\"multipart/form-data\" onsubmit=\"submitRestore(event)\">\n                            <div class=\"flex items-center gap-3\">\n                                <input type=\"file\" name=\"backup_file\" id=\"backup_file\" accept=\".json\" required\n                                    class=\"text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-100 file:text-blue-700 dark:file:bg-blue-800 dark:file:text-blue-200 hover:file:bg-blue-200 dark:hover:file:bg-blue-700\">\n                                <select name=\"mode\" class=\"text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg px-3 py-2\">\n                                    <option value=\"replace\">Replace existing data</option>\n                                    <option value=\"merge\">Merge with existing data</option>\n                                </select>\n                                <button type=\"submit\" class=\"bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 whitespace-nowrap\">\n                                    Restore\n                                </button>\n                            </div>\n                        </form>\n                        <div id=\"restore-message\" class=\"mt-2 text-sm\"></div>\n                    </div>\n\n                    <div class=\"flex items-center justify-between p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-red-800 dark:text-red-200\">Clear All Data</h4>\n                            <p class=\"text-sm text-red-700 dark:text-red-300\">Permanently delete all subscription data</p>\n                        </div>\n                        <button\n                            hx-delete=\"/api/clear-all\"\n                            hx-confirm=\"Are you sure you want to delete all subscription data? This action cannot be undone.\"\n                            hx-target=\"body\"\n                            hx-swap=\"none\"\n                            class=\"bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700\">\n                            Clear Data\n                        </button>\n                    </div>\n                </div>\n            </div>\n            \n            <!-- Email Notifications -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Email Notifications</h3>\n                \n                <!-- SMTP Settings -->\n                <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-4 transition-colors duration-200\">\n                    <h4 class=\"text-sm font-medium text-gray-900 dark:text-white mb-4\">SMTP Configuration</h4>\n                    <form id=\"smtp-form\" hx-post=\"/api/settings/smtp\" hx-trigger=\"submit\" hx-target=\"#smtp-message\" hx-swap=\"innerHTML\">\n                        <div class=\"grid grid-cols-1 md:grid-cols-2 gap-4 mb-4\">\n                            <div>\n                                <label for=\"smtp_host\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">SMTP Host</label>\n                                <input type=\"text\" id=\"smtp_host\" name=\"smtp_host\" placeholder=\"smtp.gmail.com\" value=\"{{if .SMTPConfig}}{{.SMTPConfig.Host}}{{end}}\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                            </div>\n                            <div>\n                                <label for=\"smtp_port\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">SMTP Port</label>\n                                <input type=\"number\" id=\"smtp_port\" name=\"smtp_port\" placeholder=\"587\" value=\"{{if .SMTPConfig}}{{.SMTPConfig.Port}}{{end}}\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                            </div>\n                            <div>\n                                <label for=\"smtp_username\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">Username</label>\n                                <input type=\"text\" id=\"smtp_username\" name=\"smtp_username\" placeholder=\"username or email\" value=\"{{if .SMTPConfig}}{{.SMTPConfig.Username}}{{end}}\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                            </div>\n                            <div>\n                                <label for=\"smtp_password\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">Password</label>\n                                <input type=\"password\" id=\"smtp_password\" name=\"smtp_password\" placeholder=\"••••••••\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                            </div>\n                            <div>\n                                <label for=\"smtp_from\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">From Email</label>\n                                <input type=\"email\" id=\"smtp_from\" name=\"smtp_from\" placeholder=\"noreply@example.com\" value=\"{{if .SMTPConfig}}{{.SMTPConfig.From}}{{end}}\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                            </div>\n                            <div>\n                                <label for=\"smtp_from_name\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">From Name</label>\n                                <input type=\"text\" id=\"smtp_from_name\" name=\"smtp_from_name\" placeholder=\"SubTrackr\" value=\"{{if .SMTPConfig}}{{.SMTPConfig.FromName}}{{end}}\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                            </div>\n                            <div class=\"md:col-span-2\">\n                                <label for=\"smtp_to\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">To Email (Notification Recipient)</label>\n                                <input type=\"email\" id=\"smtp_to\" name=\"smtp_to\" placeholder=\"your-email@example.com\" value=\"{{if .SMTPConfig}}{{.SMTPConfig.To}}{{end}}\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                                <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">This is where notification emails will be sent</p>\n                            </div>\n                        </div>\n                        <div class=\"mb-4\">\n                            <div id=\"smtp-message\"></div>\n                        </div>\n                        <div class=\"flex items-center justify-end\">\n                            <div class=\"flex space-x-2\">\n                                <button type=\"button\"\n                                        id=\"test-smtp-btn\"\n                                        hx-post=\"/api/settings/smtp/test\"\n                                        hx-include=\"#smtp-form\"\n                                        hx-target=\"#smtp-message\"\n                                        hx-indicator=\"#smtp-spinner\"\n                                        class=\"bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-500 flex items-center transition-colors duration-150\">\n                                    <svg id=\"smtp-spinner\" class=\"htmx-indicator animate-spin -ml-1 mr-2 h-4 w-4 text-gray-700 dark:text-gray-200\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                                        <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                                        <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                                    </svg>\n                                    Test Connection\n                                </button>\n                                <button type=\"submit\" class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90\">\n                                    Save SMTP Settings\n                                </button>\n                            </div>\n                        </div>\n                    </form>\n                </div>\n\n                <!-- Notification Preferences -->\n                <div class=\"space-y-4\">\n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-900 dark:text-white\">Renewal Reminders</h4>\n                            <p class=\"text-sm text-gray-600 dark:text-gray-300\">Get notified before subscriptions renew</p>\n                        </div>\n                        <button hx-post=\"/api/settings/notifications/renewal\" \n                                hx-trigger=\"click\"\n                                hx-swap=\"none\"\n                                id=\"renewal-toggle\"\n                                class=\"relative inline-flex h-6 w-11 items-center rounded-full {{if .RenewalReminders}}bg-primary{{else}}bg-gray-200{{end}} transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2\">\n                            <span class=\"inline-block h-4 w-4 transform rounded-full bg-white shadow-lg ring-0 transition-transform {{if .RenewalReminders}}translate-x-6{{else}}translate-x-1{{end}}\"></span>\n                        </button>\n                    </div>\n                    \n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-900 dark:text-white\">High Cost Alerts</h4>\n                            <p class=\"text-sm text-gray-600 dark:text-gray-300\">Alert when adding expensive subscriptions</p>\n                        </div>\n                        <button hx-post=\"/api/settings/notifications/highcost\"\n                                hx-trigger=\"click\"\n                                hx-swap=\"none\"\n                                id=\"highcost-toggle\" \n                                class=\"relative inline-flex h-6 w-11 items-center rounded-full {{if .HighCostAlerts}}bg-primary{{else}}bg-gray-200{{end}} transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2\">\n                            <span class=\"inline-block h-4 w-4 transform rounded-full bg-white shadow-lg ring-0 transition-transform {{if .HighCostAlerts}}translate-x-6{{else}}translate-x-1{{end}}\"></span>\n                        </button>\n                    </div>\n                    \n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-900 dark:text-white\">High Cost Threshold</h4>\n                            <p class=\"text-sm text-gray-600 dark:text-gray-300\">Monthly cost threshold for high cost alerts (in {{.CurrencySymbol}})</p>\n                        </div>\n                        <div class=\"flex items-center space-x-2\">\n                            <span class=\"text-sm text-gray-600 dark:text-gray-400\">{{.CurrencySymbol}}</span>\n                            <input type=\"number\" \n                                   name=\"high_cost_threshold\"\n                                   value=\"{{printf \"%.2f\" .HighCostThreshold}}\"\n                                   min=\"0\"\n                                   max=\"10000\"\n                                   step=\"0.01\"\n                                   hx-post=\"/api/settings/notifications/threshold\"\n                                   hx-trigger=\"change\"\n                                   hx-swap=\"none\"\n                                   class=\"w-24 px-2 py-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded text-sm focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-150\">\n                        </div>\n                    </div>\n                    \n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-900 dark:text-white\">Days Before Renewal</h4>\n                            <p class=\"text-sm text-gray-600 dark:text-gray-300\">How many days before renewal to send reminder</p>\n                        </div>\n                        <input type=\"number\"\n                               name=\"reminder_days\"\n                               value=\"{{.ReminderDays}}\"\n                               min=\"1\"\n                               max=\"30\"\n                               hx-post=\"/api/settings/notifications/days\"\n                               hx-trigger=\"change\"\n                               hx-swap=\"none\"\n                               class=\"w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded text-sm focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-150\">\n                    </div>\n\n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-900 dark:text-white\">Cancellation Reminders</h4>\n                            <p class=\"text-sm text-gray-600 dark:text-gray-300\">Get notified before subscriptions end</p>\n                        </div>\n                        <button hx-post=\"/api/settings/notifications/cancellation\"\n                                hx-trigger=\"click\"\n                                hx-swap=\"none\"\n                                id=\"cancellation-toggle\"\n                                class=\"relative inline-flex h-6 w-11 items-center rounded-full {{if .CancellationReminders}}bg-primary{{else}}bg-gray-200{{end}} transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2\">\n                            <span class=\"inline-block h-4 w-4 transform rounded-full bg-white shadow-lg ring-0 transition-transform {{if .CancellationReminders}}translate-x-6{{else}}translate-x-1{{end}}\"></span>\n                        </button>\n                    </div>\n\n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-900 dark:text-white\">Days Before Cancellation</h4>\n                            <p class=\"text-sm text-gray-600 dark:text-gray-300\">How many days before cancellation to send reminder</p>\n                        </div>\n                        <input type=\"number\"\n                               name=\"cancellation_reminder_days\"\n                               value=\"{{.CancellationReminderDays}}\"\n                               min=\"1\"\n                               max=\"30\"\n                               hx-post=\"/api/settings/notifications/cancellation_days\"\n                               hx-trigger=\"change\"\n                               hx-swap=\"none\"\n                               class=\"w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded text-sm focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-150\">\n                    </div>\n                </div>\n            </div>\n\n            <!-- Pushover Notifications -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Pushover Notifications</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Receive push notifications on your mobile device via Pushover</p>\n                \n                <!-- Pushover Settings -->\n                <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-4 transition-colors duration-200\">\n                    <h4 class=\"text-sm font-medium text-gray-900 dark:text-white mb-4\">Pushover Configuration</h4>\n                    <form id=\"pushover-form\" hx-post=\"/api/settings/pushover\" hx-trigger=\"submit\" hx-target=\"#pushover-message\" hx-swap=\"innerHTML\">\n                        <div class=\"grid grid-cols-1 md:grid-cols-2 gap-4 mb-4\">\n                            <div>\n                                <label for=\"pushover_user_key\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">User Key</label>\n                                <input type=\"text\" id=\"pushover_user_key\" name=\"pushover_user_key\" placeholder=\"Your Pushover User Key\" value=\"{{if .PushoverConfig}}{{.PushoverConfig.UserKey}}{{end}}\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                                <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">Get your User Key from <a href=\"https://pushover.net/\" target=\"_blank\" class=\"text-primary hover:underline\">pushover.net</a></p>\n                            </div>\n                            <div>\n                                <label for=\"pushover_app_token\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">Application Token</label>\n                                <input type=\"text\" id=\"pushover_app_token\" name=\"pushover_app_token\" placeholder=\"Your Application Token\" value=\"{{if .PushoverConfig}}{{.PushoverConfig.AppToken}}{{end}}\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                                <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">Create an application at <a href=\"https://pushover.net/apps/build\" target=\"_blank\" class=\"text-primary hover:underline\">pushover.net/apps</a></p>\n                            </div>\n                        </div>\n                        <div class=\"mb-4\">\n                            <div id=\"pushover-message\"></div>\n                        </div>\n                        <div class=\"flex items-center justify-end\">\n                            <div class=\"flex space-x-2\">\n                                <button type=\"button\"\n                                        id=\"test-pushover-btn\"\n                                        hx-post=\"/api/settings/pushover/test\"\n                                        hx-include=\"#pushover-form\"\n                                        hx-target=\"#pushover-message\"\n                                        hx-indicator=\"#pushover-spinner\"\n                                        class=\"bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-500 flex items-center transition-colors duration-150\">\n                                    <svg id=\"pushover-spinner\" class=\"htmx-indicator animate-spin -ml-1 mr-2 h-4 w-4 text-gray-700 dark:text-gray-200\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                                        <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                                        <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                                    </svg>\n                                    Test Connection\n                                </button>\n                                <button type=\"submit\" class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90\">\n                                    Save Pushover Settings\n                                </button>\n                            </div>\n                        </div>\n                    </form>\n                </div>\n            </div>\n\n            <!-- Webhook Notifications -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Webhook Notifications</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Send notifications to any webhook endpoint (Slack, Discord, n8n, Home Assistant, etc.)</p>\n\n                <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transition-colors duration-200\">\n                    <form id=\"webhook-form\" hx-post=\"/api/settings/webhook\" hx-trigger=\"submit\" hx-target=\"#webhook-message\" hx-swap=\"innerHTML\">\n                        <div class=\"space-y-4\">\n                            <div>\n                                <label for=\"webhook_url\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">Webhook URL</label>\n                                <input type=\"url\" id=\"webhook_url\" name=\"webhook_url\"\n                                       value=\"{{if .WebhookConfig}}{{.WebhookConfig.URL}}{{end}}\"\n                                       placeholder=\"https://example.com/webhook\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-150\">\n                            </div>\n                            <div>\n                                <label for=\"webhook_headers\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">Custom Headers <span class=\"text-gray-400 font-normal\">(optional, one per line)</span></label>\n                                <textarea id=\"webhook_headers\" name=\"webhook_headers\" rows=\"3\"\n                                          placeholder=\"Authorization: Bearer your-token-here&#10;X-Custom-Header: value\"\n                                          class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-primary focus:border-primary font-mono transition-colors duration-150\">{{if .WebhookConfig}}{{range $key, $value := .WebhookConfig.Headers}}{{$key}}: {{$value}}\n{{end}}{{end}}</textarea>\n                            </div>\n                            <div id=\"webhook-message\"></div>\n                            <div class=\"flex justify-end space-x-3\">\n                                <button type=\"button\"\n                                        hx-post=\"/api/settings/webhook/test\"\n                                        hx-include=\"#webhook-form\"\n                                        hx-target=\"#webhook-message\"\n                                        hx-indicator=\"#webhook-spinner\"\n                                        class=\"bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-500 flex items-center transition-colors duration-150\">\n                                    <svg id=\"webhook-spinner\" class=\"htmx-indicator animate-spin -ml-1 mr-2 h-4 w-4 text-gray-700 dark:text-gray-200\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                                        <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                                        <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                                    </svg>\n                                    Test Connection\n                                </button>\n                                <button type=\"submit\" class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90\">\n                                    Save Webhook Settings\n                                </button>\n                            </div>\n                        </div>\n                    </form>\n                </div>\n            </div>\n\n            <!-- Security Settings -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Security</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Protect your SubTrackr instance with login authentication</p>\n\n                <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transition-colors duration-200\">\n                    <div class=\"flex items-center justify-between mb-4\">\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-900 dark:text-white\">Require Login</h4>\n                            <p class=\"text-sm text-gray-600 dark:text-gray-300\">Enable authentication to protect your data</p>\n                        </div>\n                        <label class=\"relative inline-flex items-center cursor-pointer\">\n                            <input type=\"checkbox\"\n                                   id=\"auth-toggle\"\n                                   value=\"\"\n                                   class=\"sr-only peer\"\n                                   {{if .AuthEnabled}}checked{{end}}\n                                   onchange=\"toggleAuthForm()\">\n                            <div class=\"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600\"></div>\n                        </label>\n                    </div>\n\n                    <!-- Auth Setup Form (shown when toggle is ON) -->\n                    <div id=\"auth-form-container\" class=\"{{if not .AuthEnabled}}hidden{{end}} mt-4 pt-4 border-t border-gray-300 dark:border-gray-600\">\n                        {{if .AuthEnabled}}\n                        <!-- Already configured - show disable option -->\n                        <div class=\"space-y-3\">\n                            <p class=\"text-sm text-green-600 dark:text-green-400\">✓ Authentication is enabled</p>\n                            <p class=\"text-sm text-gray-600 dark:text-gray-300\">Username: <span class=\"font-medium\">{{.AuthUsername}}</span></p>\n                            <button\n                                hx-post=\"/api/settings/auth/disable\"\n                                hx-target=\"#auth-message\"\n                                hx-swap=\"innerHTML\"\n                                onclick=\"document.getElementById('auth-toggle').checked = false; toggleAuthForm();\"\n                                class=\"bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700\">\n                                Disable Authentication\n                            </button>\n                        </div>\n                        {{else}}\n                        <!-- Setup form for new auth -->\n                        <form id=\"auth-setup-form\" hx-post=\"/api/settings/auth/setup\" hx-target=\"#auth-message\" hx-swap=\"innerHTML\">\n                            <div class=\"space-y-4\">\n                                <div class=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3\">\n                                    <p class=\"text-sm text-blue-800 dark:text-blue-200\">\n                                        ⓘ Email must be configured first for password recovery. {{if not .SMTPConfigured}}<span class=\"font-medium text-red-600 dark:text-red-400\">Please configure email settings above before enabling login.</span>{{end}}\n                                    </p>\n                                </div>\n\n                                <div>\n                                    <label for=\"auth_username\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">Username</label>\n                                    <input type=\"text\" id=\"auth_username\" name=\"username\" placeholder=\"admin\" required\n                                           {{if not .SMTPConfigured}}disabled{{end}}\n                                           class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150 disabled:opacity-50\">\n                                </div>\n\n                                <div>\n                                    <label for=\"auth_password\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">Password</label>\n                                    <input type=\"password\" id=\"auth_password\" name=\"password\" placeholder=\"••••••••\" required minlength=\"8\"\n                                           {{if not .SMTPConfigured}}disabled{{end}}\n                                           class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150 disabled:opacity-50\">\n                                    <p class=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">Minimum 8 characters</p>\n                                </div>\n\n                                <div>\n                                    <label for=\"auth_confirm_password\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">Confirm Password</label>\n                                    <input type=\"password\" id=\"auth_confirm_password\" name=\"confirm_password\" placeholder=\"••••••••\" required minlength=\"8\"\n                                           {{if not .SMTPConfigured}}disabled{{end}}\n                                           class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150 disabled:opacity-50\">\n                                </div>\n\n                                <button\n                                    type=\"submit\"\n                                    {{if not .SMTPConfigured}}disabled{{end}}\n                                    class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed\">\n                                    Enable Authentication\n                                </button>\n                            </div>\n                        </form>\n                        {{end}}\n                    </div>\n\n                    <div id=\"auth-message\" class=\"mt-4\"></div>\n                </div>\n            </div>\n\n            \n            <!-- Currency Settings -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Currency</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Choose your preferred currency for displaying subscription costs</p>\n                \n                <div class=\"grid grid-cols-2 md:grid-cols-3 gap-3\">\n                    {{range .Currencies}}\n                    <label class=\"flex items-center cursor-pointer\">\n                        <input type=\"radio\"\n                               name=\"currency\"\n                               value=\"{{.Code}}\"\n                               {{if eq .Code $.Currency}}checked{{end}}\n                               hx-post=\"/api/settings/currency\"\n                               hx-trigger=\"change\"\n                               hx-vals='{\"currency\": \"{{.Code}}\"}'\n                               class=\"mr-2 text-primary focus:ring-primary\">\n                        <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">{{.Symbol}} {{.Code}} ({{.Name}})</span>\n                    </label>\n                    {{end}}\n                </div>\n\n                <div id=\"currency-message\" class=\"mt-2\"></div>\n            </div>\n\n            <!-- Date Format Settings -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Date Format</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Choose how dates are displayed throughout the application</p>\n\n                <div class=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n                    <label class=\"flex items-center cursor-pointer\">\n                        <input type=\"radio\"\n                               name=\"date_format\"\n                               value=\"MM/DD/YYYY\"\n                               {{if or (eq .DateFormat \"MM/DD/YYYY\") (eq .DateFormat \"\")}}checked{{end}}\n                               hx-post=\"/api/settings/date-format\"\n                               hx-trigger=\"change\"\n                               hx-vals='{\"date_format\": \"MM/DD/YYYY\"}'\n                               class=\"mr-2 text-primary focus:ring-primary\">\n                        <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">MM/DD/YYYY (02/24/2026)</span>\n                    </label>\n\n                    <label class=\"flex items-center cursor-pointer\">\n                        <input type=\"radio\"\n                               name=\"date_format\"\n                               value=\"DD/MM/YYYY\"\n                               {{if eq .DateFormat \"DD/MM/YYYY\"}}checked{{end}}\n                               hx-post=\"/api/settings/date-format\"\n                               hx-trigger=\"change\"\n                               hx-vals='{\"date_format\": \"DD/MM/YYYY\"}'\n                               class=\"mr-2 text-primary focus:ring-primary\">\n                        <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">DD/MM/YYYY (24/02/2026)</span>\n                    </label>\n\n                    <label class=\"flex items-center cursor-pointer\">\n                        <input type=\"radio\"\n                               name=\"date_format\"\n                               value=\"YYYY-MM-DD\"\n                               {{if eq .DateFormat \"YYYY-MM-DD\"}}checked{{end}}\n                               hx-post=\"/api/settings/date-format\"\n                               hx-trigger=\"change\"\n                               hx-vals='{\"date_format\": \"YYYY-MM-DD\"}'\n                               class=\"mr-2 text-primary focus:ring-primary\">\n                        <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">YYYY-MM-DD (2026-02-24)</span>\n                    </label>\n                </div>\n\n                <div id=\"date-format-message\" class=\"mt-2\"></div>\n            </div>\n\n            <!-- Category Management -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">Categories</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Manage your subscription categories</p>\n                <div id=\"categories-list\" class=\"space-y-2 mb-4\"></div>\n                <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transition-colors duration-200\">\n                    <h4 class=\"text-sm font-medium text-gray-900 dark:text-white mb-3\">Add New Category</h4>\n                    <form id=\"add-category-form\">\n                        <div class=\"flex items-end space-x-3\">\n                            <div class=\"flex-1\">\n                                <label for=\"category_name\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">Category Name</label>\n                                <input type=\"text\" id=\"category_name\" name=\"name\" required placeholder=\"e.g., Streaming\" class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                            </div>\n                            <button type=\"submit\" class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90\">Add Category</button>\n                        </div>\n                    </form>\n                </div>\n            </div>\n            \n            <!-- API Keys -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">API Keys</h3>\n                <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Create API keys to access SubTrackr from external applications</p>\n                \n                <!-- API Key List -->\n                <div id=\"api-keys-list\" class=\"space-y-2 mb-4\">\n                    <div hx-get=\"/api/settings/apikeys\" hx-trigger=\"load\" hx-swap=\"innerHTML\">\n                        <div class=\"text-center py-4 text-gray-500\">\n                            Loading API keys...\n                        </div>\n                    </div>\n                </div>\n                \n                <!-- Create New API Key -->\n                <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transition-colors duration-200\">\n                    <h4 class=\"text-sm font-medium text-gray-900 dark:text-white mb-3\">Create New API Key</h4>\n                    <form hx-post=\"/api/settings/apikeys\" hx-target=\"#api-keys-list\" hx-swap=\"innerHTML\">\n                        <div class=\"flex items-end space-x-3\">\n                            <div class=\"flex-1\">\n                                <label for=\"api_key_name\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1\">Key Name</label>\n                                <input type=\"text\" \n                                       id=\"api_key_name\" \n                                       name=\"name\" \n                                       required\n                                       placeholder=\"e.g., Home Assistant Integration\"\n                                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm transition-colors duration-150\">\n                            </div>\n                            <button type=\"submit\" class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90\">\n                                Generate API Key\n                            </button>\n                        </div>\n                    </form>\n                </div>\n                \n                <!-- API Documentation Link -->\n                <div class=\"mt-4 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg transition-colors duration-200\">\n                    <p class=\"text-sm text-blue-800 dark:text-blue-200\">\n                        <strong>API Documentation:</strong> Include the API key in the <code class=\"bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded text-blue-800 dark:text-blue-200\">X-API-Key</code> header for all requests.\n                        <a href=\"#api-docs\" class=\"text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline ml-1\">View full API documentation</a>\n                    </p>\n                </div>\n            </div>\n            \n            <!-- About -->\n            <div class=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\n                <h3 class=\"text-base font-medium text-gray-900 dark:text-white mb-4\">About SubTrackr</h3>\n                <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transition-colors duration-200\">\n                    <div class=\"flex items-center justify-between mb-2\">\n                        <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">Version</span>\n                        <span class=\"text-sm text-gray-600 dark:text-gray-300\">{{.Version}}</span>\n                    </div>\n                    <div class=\"flex items-center justify-between mb-2\">\n                        <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">Build</span>\n                        <span class=\"text-sm text-gray-600 dark:text-gray-300\">Go + HTMX</span>\n                    </div>\n                    <div class=\"flex items-center justify-between\">\n                        <span class=\"text-sm font-medium text-gray-700 dark:text-gray-200\">Database</span>\n                        <span class=\"text-sm text-gray-600 dark:text-gray-300\">SQLite</span>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n<!-- API Documentation -->\n<div id=\"api-docs\" class=\"mt-6\">\n    <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow-sm transition-colors duration-200\">\n        <div class=\"p-6\">\n            <h2 class=\"text-lg font-semibold text-gray-900 dark:text-white mb-4\">API Documentation</h2>\n            \n            <div class=\"space-y-6\">\n                <!-- Authentication -->\n                <div>\n                    <h3 class=\"text-md font-medium text-gray-900 dark:text-white mb-2\">Authentication</h3>\n                    <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-3\">All API requests require authentication using an API key. Include your API key in the request headers:</p>\n                    <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 font-mono text-sm transition-colors duration-200\">\n                        <div class=\"mb-2\">\n                            <span class=\"text-gray-500 dark:text-gray-400\"># Authorization header (recommended)</span><br>\n                            <span class=\"text-gray-900 dark:text-gray-100\">Authorization: Bearer sk_your_api_key_here</span>\n                        </div>\n                        <div>\n                            <span class=\"text-gray-500 dark:text-gray-400\"># X-API-Key header (alternative)</span><br>\n                            <span class=\"text-gray-900 dark:text-gray-100\">X-API-Key: sk_your_api_key_here</span>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Endpoints -->\n                <div>\n                    <h3 class=\"text-md font-medium text-gray-900 dark:text-white mb-3\">API Endpoints</h3>\n                    \n                    <!-- Subscriptions -->\n                    <div class=\"mb-4\">\n                        <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200 mb-2\">Subscriptions</h4>\n                        <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg overflow-hidden transition-colors duration-200\">\n                            <table class=\"min-w-full\">\n                                <thead class=\"bg-gray-100 dark:bg-gray-600\">\n                                    <tr>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Method</th>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Endpoint</th>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Description</th>\n                                    </tr>\n                                </thead>\n                                <tbody class=\"divide-y divide-gray-200 dark:divide-gray-600\">\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-xs font-medium\">GET</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/api/v1/subscriptions</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">List all subscriptions</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded text-xs font-medium\">POST</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/api/v1/subscriptions</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Create a new subscription</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-xs font-medium\">GET</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/api/v1/subscriptions/:id</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Get subscription details</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-300 rounded text-xs font-medium\">PUT</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/api/v1/subscriptions/:id</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Update subscription</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded text-xs font-medium\">DELETE</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/api/v1/subscriptions/:id</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Delete subscription</td>\n                                    </tr>\n                                </tbody>\n                            </table>\n                        </div>\n                    </div>\n\n                    <!-- Statistics & Export -->\n                    <div>\n                        <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200 mb-2\">Statistics & Export</h4>\n                        <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg overflow-hidden transition-colors duration-200\">\n                            <table class=\"min-w-full\">\n                                <thead class=\"bg-gray-100 dark:bg-gray-600\">\n                                    <tr>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Method</th>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Endpoint</th>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Description</th>\n                                    </tr>\n                                </thead>\n                                <tbody class=\"divide-y divide-gray-200 dark:divide-gray-600\">\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-xs font-medium\">GET</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/api/v1/stats</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Get subscription statistics</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-xs font-medium\">GET</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/api/v1/export/csv</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Export subscriptions as CSV</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-xs font-medium\">GET</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/api/v1/export/json</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Export subscriptions as JSON</td>\n                                    </tr>\n                                </tbody>\n                            </table>\n                        </div>\n                    </div>\n\n                    <!-- iCal Subscription -->\n                    <div>\n                        <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200 mb-2\">iCal Subscription</h4>\n                        <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg overflow-hidden transition-colors duration-200\">\n                            <table class=\"min-w-full\">\n                                <thead class=\"bg-gray-100 dark:bg-gray-600\">\n                                    <tr>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Method</th>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Endpoint</th>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Description</th>\n                                    </tr>\n                                </thead>\n                                <tbody class=\"divide-y divide-gray-200 dark:divide-gray-600\">\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm\"><span class=\"px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-xs font-medium\">GET</span></td>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">/ical/:token</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Subscribe to iCal feed (public, token-authenticated)</td>\n                                    </tr>\n                                </tbody>\n                            </table>\n                        </div>\n                    </div>\n\n                    <!-- MCP Server -->\n                    <div>\n                        <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200 mb-2\">MCP Server (AI Integration)</h4>\n                        <p class=\"text-sm text-gray-600 dark:text-gray-300 mb-3\">SubTrackr includes a Model Context Protocol (MCP) server that allows AI assistants like Claude to read and manage your subscriptions via natural language.</p>\n                        <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg overflow-hidden transition-colors duration-200 mb-3\">\n                            <table class=\"min-w-full\">\n                                <thead class=\"bg-gray-100 dark:bg-gray-600\">\n                                    <tr>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Tool</th>\n                                        <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-200\">Description</th>\n                                    </tr>\n                                </thead>\n                                <tbody class=\"divide-y divide-gray-200 dark:divide-gray-600\">\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">list_subscriptions</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">List all subscriptions</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">get_subscription</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Get subscription by ID</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">create_subscription</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Create a new subscription</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">update_subscription</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Update an existing subscription</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">delete_subscription</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Delete a subscription</td>\n                                    </tr>\n                                    <tr>\n                                        <td class=\"px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100\">get_stats</td>\n                                        <td class=\"px-4 py-2 text-sm text-gray-600 dark:text-gray-300\">Get subscription statistics</td>\n                                    </tr>\n                                </tbody>\n                            </table>\n                        </div>\n                        <h5 class=\"text-xs font-medium text-gray-700 dark:text-gray-200 mb-2\">Configuration</h5>\n                        <p class=\"text-xs text-gray-600 dark:text-gray-300 mb-2\">Add this to your Claude Desktop or Claude Code MCP config:</p>\n                        <div class=\"bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-xs overflow-x-auto\">\n<pre>{\n  \"mcpServers\": {\n    \"subtrackr\": {\n      \"command\": \"subtrackr-mcp\",\n      \"args\": [],\n      \"env\": {\n        \"DATABASE_PATH\": \"/path/to/subtrackr.db\"\n      }\n    }\n  }\n}</pre>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Example Requests -->\n                <div>\n                    <h3 class=\"text-md font-medium text-gray-900 dark:text-white mb-3\">Example Requests</h3>\n                    \n                    <div class=\"space-y-4\">\n                        <!-- List Subscriptions -->\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200 mb-2\">List Subscriptions</h4>\n                            <div class=\"bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-sm overflow-x-auto\">\n                                <div class=\"text-green-400\">curl -H \"Authorization: Bearer sk_your_api_key_here\" \\</div>\n                                <div class=\"ml-4\">http://localhost:8080/api/v1/subscriptions</div>\n                            </div>\n                        </div>\n\n                        <!-- Create Subscription -->\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200 mb-2\">Create Subscription</h4>\n                            <div class=\"bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-sm overflow-x-auto\">\n                                <div class=\"text-green-400\">curl -X POST \\</div>\n                                <div class=\"ml-4\">-H \"Authorization: Bearer sk_your_api_key_here\" \\</div>\n                                <div class=\"ml-4\">-H \"Content-Type: application/json\" \\</div>\n                                <div class=\"ml-4\">-d '{</div>\n                                <div class=\"ml-8\">\"name\": \"Netflix\",</div>\n                                <div class=\"ml-8\">\"cost\": 15.99,</div>\n                                <div class=\"ml-8\">\"schedule\": \"Monthly\",</div>\n                                <div class=\"ml-8\">\"status\": \"Active\",</div>\n                                <div class=\"ml-8\">\"category_id\": 1</div>\n                                <div class=\"ml-4\">}' \\</div>\n                                <div class=\"ml-4\">http://localhost:8080/api/v1/subscriptions</div>\n                            </div>\n                        </div>\n\n                        <!-- Get Statistics -->\n                        <div>\n                            <h4 class=\"text-sm font-medium text-gray-700 dark:text-gray-200 mb-2\">Get Statistics</h4>\n                            <div class=\"bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-sm overflow-x-auto\">\n                                <div class=\"text-green-400\">curl -H \"Authorization: Bearer sk_your_api_key_here\" \\</div>\n                                <div class=\"ml-4\">http://localhost:8080/api/v1/stats</div>\n                            </div>\n                            <div class=\"mt-2\">\n                                <h5 class=\"text-xs font-medium text-gray-600 dark:text-gray-300 mb-1\">Response:</h5>\n                                <div class=\"bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 font-mono text-xs transition-colors duration-200\">\n<pre>{\n  \"total_count\": 15,\n  \"active_count\": 12,\n  \"total_cost\": 245.67,\n  \"categories\": {\n    \"Entertainment\": 45.99,\n    \"Productivity\": 89.00,\n    \"Storage\": 29.99\n  }\n}</pre>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Additional Resources -->\n                <div>\n                    <h3 class=\"text-md font-medium text-gray-900 dark:text-white mb-2\">Additional Resources</h3>\n                    <div class=\"bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 transition-colors duration-200\">\n                        <div class=\"flex items-start\">\n                            <svg class=\"w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-2\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                <path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z\" clip-rule=\"evenodd\"></path>\n                            </svg>\n                            <div class=\"text-sm text-blue-800 dark:text-blue-200\">\n                                <p>For more detailed API documentation and examples, check the README file in the project repository.</p>\n                                <p class=\"mt-1\">The test script <code class=\"bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded text-blue-800 dark:text-blue-200\">test-api.sh</code> provides interactive examples of API usage.</p>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n            </div>\n        </main>\n    </div>\n\n    <!-- Modal -->\n    <div id=\"modal\" class=\"hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50\">\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4 transition-colors duration-200\">\n            <div id=\"modal-content\">\n                <!-- Dynamic content loaded here -->\n            </div>\n        </div>\n    </div>\n\n    <script>\n        // Mobile menu functionality is handled by /static/js/mobile-menu.js\n\n        // Close modal when clicking outside\n        document.getElementById('modal').addEventListener('click', function(e) {\n            if (e.target === this) {\n                this.classList.add('hidden');\n            }\n        });\n\n        // Handle toggle button responses\n        function updateToggle(response, toggleId) {\n            const toggle = document.getElementById(toggleId);\n            const span = toggle.querySelector('span');\n            \n            if (response.enabled) {\n                toggle.classList.remove('bg-gray-200');\n                toggle.classList.add('bg-primary');\n                span.classList.remove('translate-x-1');\n                span.classList.add('translate-x-6');\n            } else {\n                toggle.classList.remove('bg-primary');\n                toggle.classList.add('bg-gray-200');\n                span.classList.remove('translate-x-6');\n                span.classList.add('translate-x-1');\n            }\n        }\n\n        document.body.addEventListener('htmx:afterRequest', function(evt) {\n            const path = evt.detail.pathInfo.requestPath;\n            \n            // Only parse JSON for notification endpoints\n            if (path.includes('/api/settings/notifications/')) {\n                try {\n                    const response = JSON.parse(evt.detail.xhr.responseText);\n                    \n                    if (path === '/api/settings/notifications/renewal') {\n                        updateToggle(response, 'renewal-toggle');\n                    }\n                    \n                    if (path === '/api/settings/notifications/highcost') {\n                        updateToggle(response, 'highcost-toggle');\n                    }\n                } catch (e) {\n                    // Response is not JSON, ignore\n                }\n            }\n        });\n        function submitRestore(event) {\n            event.preventDefault();\n            const form = document.getElementById('restore-form');\n            const formData = new FormData(form);\n            const msgDiv = document.getElementById('restore-message');\n            const mode = formData.get('mode');\n\n            if (mode === 'replace') {\n                if (!confirm('This will replace all existing subscription data with the backup. Continue?')) {\n                    return;\n                }\n            }\n\n            msgDiv.textContent = 'Restoring backup...';\n            msgDiv.className = 'mt-2 text-sm text-blue-600 dark:text-blue-400';\n\n            fetch('/api/restore', {\n                method: 'POST',\n                body: formData,\n            })\n            .then(r => r.json().then(data => ({status: r.status, ok: r.ok, data})))\n            .then(({status, ok, data}) => {\n                if (status === 207) {\n                    msgDiv.textContent = data.message + ` (${data.errors.length} failed)`;\n                    msgDiv.className = 'mt-2 text-sm text-yellow-600 dark:text-yellow-400';\n                    setTimeout(() => location.reload(), 2000);\n                } else if (ok) {\n                    msgDiv.textContent = data.message;\n                    msgDiv.className = 'mt-2 text-sm text-green-600 dark:text-green-400';\n                    setTimeout(() => location.reload(), 1500);\n                } else {\n                    msgDiv.textContent = data.error;\n                    msgDiv.className = 'mt-2 text-sm text-red-600 dark:text-red-400';\n                }\n            })\n            .catch(() => {\n                msgDiv.textContent = 'Failed to restore backup';\n                msgDiv.className = 'mt-2 text-sm text-red-600 dark:text-red-400';\n            });\n        }\n    </script>\n    <script>\n// --- Category Management Vanilla JS ---\nfunction renderCategories(categories) {\n    const list = document.getElementById('categories-list');\n    if (!categories.length) {\n        list.innerHTML = '<div class=\"text-center py-4 text-gray-500\">No categories found.</div>';\n        return;\n    }\n    list.innerHTML = categories.map(cat => `\n        <div class=\"flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg\">\n            <div class=\"flex-1\">\n                <span class=\"category-name text-sm font-medium text-gray-900\" id=\"category-name-${cat.id}\">${cat.name}</span>\n                <form id=\"edit-category-form-${cat.id}\" class=\"hidden inline\">\n                    <input type=\"text\" name=\"name\" value=\"${cat.name}\" class=\"px-2 py-1 border border-gray-300 rounded text-sm\">\n                    <button type=\"submit\" class=\"text-primary text-sm font-medium ml-2\">Save</button>\n                    <button type=\"button\" onclick=\"cancelEdit(${cat.id})\" class=\"text-gray-500 text-sm ml-1\">Cancel</button>\n                </form>\n            </div>\n            <div class=\"flex items-center space-x-2\">\n                <button onclick=\"startEdit(${cat.id})\" class=\"text-blue-600 hover:text-blue-800 text-sm font-medium\">Edit</button>\n                <button onclick=\"deleteCategory(${cat.id})\" class=\"text-red-600 hover:text-red-800 text-sm font-medium\">Delete</button>\n            </div>\n        </div>\n    `).join('');\n    // Attach edit form listeners\n    categories.forEach(cat => {\n        const form = document.getElementById(`edit-category-form-${cat.id}`);\n        if (form) {\n            form.onsubmit = function(e) {\n                e.preventDefault();\n                const name = form.elements['name'].value;\n                fetch(`/api/categories/${cat.id}`, {\n                    method: 'PUT',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ name })\n                }).then(r => r.json()).then(loadCategories);\n            };\n        }\n    });\n}\nfunction loadCategories() {\n    fetch('/api/categories').then(r => r.json()).then(renderCategories);\n}\nfunction addCategory(e) {\n    e.preventDefault();\n    const name = document.getElementById('category_name').value;\n    fetch('/api/categories', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ name })\n    }).then(r => r.json()).then(() => {\n        document.getElementById('add-category-form').reset();\n        loadCategories();\n    });\n}\nfunction deleteCategory(id) {\n    if (!confirm('Delete this category?')) return;\n    fetch(`/api/categories/${id}`, { method: 'DELETE' })\n        .then(async response => {\n            if (!response.ok) {\n                const data = await response.json();\n                alert(data.error || \"Failed to delete category.\");\n            } else {\n                loadCategories();\n            }\n        });\n}\nfunction startEdit(id) {\n    document.getElementById(`category-name-${id}`).style.display = 'none';\n    document.getElementById(`edit-category-form-${id}`).classList.remove('hidden');\n}\nfunction cancelEdit(id) {\n    document.getElementById(`edit-category-form-${id}`).classList.add('hidden');\n    document.getElementById(`category-name-${id}`).style.display = '';\n}\ndocument.getElementById('add-category-form').onsubmit = addCategory;\nwindow.loadCategories = loadCategories;\ndocument.addEventListener('DOMContentLoaded', loadCategories);\n\n// Auth form toggle\nfunction toggleAuthForm() {\n    const toggle = document.getElementById('auth-toggle');\n    const container = document.getElementById('auth-form-container');\n\n    if (toggle.checked) {\n        container.classList.remove('hidden');\n    } else {\n        container.classList.add('hidden');\n    }\n}\n</script>\n</body>\n</html>\n"
  },
  {
    "path": "templates/smtp-message.html",
    "content": "{{if .Error}}\n<div class=\"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 transition-colors duration-200\">\n    <div class=\"flex items-center\">\n        <svg class=\"w-4 h-4 text-red-400 dark:text-red-500 mr-2\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n            <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z\" clip-rule=\"evenodd\"></path>\n        </svg>\n        <p class=\"text-sm text-red-700 dark:text-red-300\">{{.Error}}</p>\n    </div>\n</div>\n{{else if .Message}}\n<div class=\"bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 transition-colors duration-200\">\n    <div class=\"flex items-center\">\n        <svg class=\"w-4 h-4 text-green-400 dark:text-green-500 mr-2\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n            <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\" clip-rule=\"evenodd\"></path>\n        </svg>\n        <p class=\"text-sm text-green-700 dark:text-green-300\">{{.Message}}</p>\n    </div>\n</div>\n{{end}}"
  },
  {
    "path": "templates/subscription-form.html",
    "content": "<div class=\"p-6\">\n    <div class=\"flex items-center justify-between mb-6\">\n        <h3 class=\"text-lg font-semibold text-gray-900 dark:text-white\">\n            {{if .IsEdit}}Edit{{else}}Add{{end}} Subscription\n        </h3>\n        <button onclick=\"document.getElementById('modal').classList.add('hidden')\" class=\"text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors duration-150\">\n            <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n            </svg>\n        </button>\n    </div>\n\n    <div id=\"form-errors\" class=\"mb-4\"></div>\n\n    <form {{if .IsEdit}}hx-put=\"/api/subscriptions/{{.Subscription.ID}}\"{{else}}hx-post=\"/api/subscriptions\"{{end}} \n          hx-target=\"#form-errors\"\n          hx-swap=\"innerHTML\"\n          hx-on::after-request=\"if(event.detail.successful) { if(event.detail.xhr.status === 201 || event.detail.xhr.status === 200) { window.location.reload(); } }\">\n        <div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n            <!-- Name -->\n            <div class=\"md:col-span-2\">\n                <label for=\"name\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Name *</label>\n                <input type=\"text\" id=\"name\" name=\"name\" required\n                       value=\"{{if .Subscription}}{{.Subscription.Name}}{{end}}\"\n                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n            </div>\n\n            <!-- Category -->\n            <div>\n                <label for=\"category_id\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Category *</label>\n                <div class=\"flex gap-2\">\n                    <div class=\"flex-1\" id=\"category-select-container\">\n                        <select id=\"category_id\" name=\"category_id\" required\n                                class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n                            <option value=\"\">Select category</option>\n                            {{range .Categories}}\n                                <option value=\"{{.ID}}\" {{if $.Subscription}}{{if eq $.Subscription.CategoryID .ID}}selected{{end}}{{end}}>{{.Name}}</option>\n                            {{end}}\n                        </select>\n                    </div>\n                    <button type=\"button\" id=\"add-category-btn\" onclick=\"showNewCategoryInput()\"\n                            class=\"px-3 py-2 text-sm font-medium text-primary bg-primary/10 border border-primary/30 rounded-lg hover:bg-primary/20 transition-colors duration-150\"\n                            title=\"Add new category\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 4v16m8-8H4\"></path>\n                        </svg>\n                    </button>\n                </div>\n                <!-- Inline new category input (hidden by default) -->\n                <div id=\"new-category-container\" class=\"hidden mt-2\">\n                    <div class=\"flex gap-2\">\n                        <input type=\"text\" id=\"new-category-name\" placeholder=\"New category name\"\n                               class=\"flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n                        <button type=\"button\" onclick=\"createNewCategory()\"\n                                class=\"px-3 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors duration-150\">\n                            Add\n                        </button>\n                        <button type=\"button\" onclick=\"hideNewCategoryInput()\"\n                                class=\"px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-150\">\n                            Cancel\n                        </button>\n                    </div>\n                    <div id=\"new-category-error\" class=\"text-sm text-danger mt-1 hidden\"></div>\n                </div>\n            </div>\n\n            <!-- Cost -->\n            <div>\n                <label for=\"cost\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Cost *</label>\n                <div class=\"relative\">\n                    <span class=\"absolute left-3 top-2 text-gray-500 dark:text-gray-400\">{{.CurrencySymbol}}</span>\n                    <input type=\"number\" id=\"cost\" name=\"cost\" step=\"0.01\" min=\"0\" required\n                           value=\"{{if .Subscription}}{{.Subscription.Cost}}{{end}}\"\n                           class=\"w-full pl-8 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n                </div>\n            </div>\n\n            <!-- Original Currency -->\n            <div>\n                <label for=\"original_currency\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Currency *</label>\n                <select id=\"original_currency\" name=\"original_currency\" required\n                        class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n                    {{range .Currencies}}\n                    <option value=\"{{.Code}}\" {{if $.Subscription}}{{if eq $.Subscription.OriginalCurrency .Code}}selected{{end}}{{else}}{{if eq .Code \"USD\"}}selected{{end}}{{end}}>{{.Symbol}} {{.Code}}</option>\n                    {{end}}\n                </select>\n            </div>\n\n            <!-- Schedule -->\n            <div>\n                <label for=\"schedule_combo\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Schedule *</label>\n                <select id=\"schedule_combo\" required\n                        onchange=\"updateScheduleFields()\"\n                        class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n                    <option value=\"\">Select schedule</option>\n                    <optgroup label=\"Common\">\n                        <option value=\"Daily_1\">Daily</option>\n                        <option value=\"Weekly_1\">Weekly</option>\n                        <option value=\"Weekly_2\">Every 2 Weeks</option>\n                        <option value=\"Monthly_1\">Monthly</option>\n                        <option value=\"Monthly_2\">Every 2 Months</option>\n                        <option value=\"Quarterly_1\">Quarterly</option>\n                        <option value=\"Monthly_6\">Every 6 Months</option>\n                        <option value=\"Annual_1\">Annual</option>\n                    </optgroup>\n                    <optgroup label=\"Multi-Year\">\n                        <option value=\"Annual_2\">Every 2 Years</option>\n                        <option value=\"Annual_3\">Every 3 Years</option>\n                        <option value=\"Annual_5\">Every 5 Years</option>\n                        <option value=\"Annual_10\">Every 10 Years</option>\n                    </optgroup>\n                </select>\n                <input type=\"hidden\" id=\"schedule\" name=\"schedule\" value=\"{{if .Subscription}}{{.Subscription.Schedule}}{{end}}\">\n                <input type=\"hidden\" id=\"schedule_interval\" name=\"schedule_interval\" value=\"{{if .Subscription}}{{.Subscription.ScheduleInterval}}{{else}}1{{end}}\">\n            </div>\n\n            <!-- Status -->\n            <div>\n                <label for=\"status\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Status *</label>\n                <select id=\"status\" name=\"status\" required\n                        class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n                    <option value=\"\">Select status</option>\n                    <option value=\"Active\" {{if .Subscription}}{{if eq .Subscription.Status \"Active\"}}selected{{end}}{{end}}>Active</option>\n                    <option value=\"Cancelled\" {{if .Subscription}}{{if eq .Subscription.Status \"Cancelled\"}}selected{{end}}{{end}}>Cancelled</option>\n                    <option value=\"Paused\" {{if .Subscription}}{{if eq .Subscription.Status \"Paused\"}}selected{{end}}{{end}}>Paused</option>\n                    <option value=\"Trial\" {{if .Subscription}}{{if eq .Subscription.Status \"Trial\"}}selected{{end}}{{end}}>Trial</option>\n                </select>\n            </div>\n\n            <!-- Payment Method -->\n            <div>\n                <label for=\"payment_method\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Payment Method</label>\n                <input type=\"text\" id=\"payment_method\" name=\"payment_method\"\n                       value=\"{{if .Subscription}}{{.Subscription.PaymentMethod}}{{end}}\"\n                       placeholder=\"e.g., Visa ****1234\"\n                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n            </div>\n\n            <!-- Account -->\n            <div>\n                <label for=\"account\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Account</label>\n                <input type=\"text\" id=\"account\" name=\"account\"\n                       value=\"{{if .Subscription}}{{.Subscription.Account}}{{end}}\"\n                       placeholder=\"Account email or username\"\n                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n            </div>\n\n            <!-- URL -->\n            <div class=\"md:col-span-2\">\n                <label for=\"url\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Website URL</label>\n                <input type=\"url\" id=\"url\" name=\"url\"\n                       value=\"{{if .Subscription}}{{.Subscription.URL}}{{end}}\"\n                       placeholder=\"https://example.com\"\n                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n            </div>\n\n            <!-- Start Date -->\n            <div>\n                <label for=\"start_date\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Start Date</label>\n                <input type=\"date\" id=\"start_date\" name=\"start_date\"\n                       value=\"{{if .Subscription}}{{if .Subscription.StartDate}}{{.Subscription.StartDate.Format \"2006-01-02\"}}{{end}}{{end}}\"\n                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n            </div>\n\n            <!-- Renewal Date -->\n            <div>\n                <label for=\"renewal_date\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\n                    <span class=\"flex items-center\">\n                        Next Renewal\n                        <div class=\"relative group ml-1\">\n                            <svg class=\"w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 cursor-help\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                            </svg>\n                            <div class=\"absolute left-0 bottom-full mb-2 hidden group-hover:block z-10 w-56\">\n                                <div class=\"bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg\">\n                                    Renewal date will automatically update after you click Update\n                                    <div class=\"absolute left-2 top-full -mt-1\">\n                                        <div class=\"border-4 border-transparent border-t-gray-900 dark:border-t-gray-800\"></div>\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    </span>\n                </label>\n                <input type=\"date\" id=\"renewal_date\" name=\"renewal_date\"\n                       value=\"{{if .Subscription}}{{if .Subscription.RenewalDate}}{{.Subscription.RenewalDate.Format \"2006-01-02\"}}{{end}}{{end}}\"\n                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n            </div>\n\n            <!-- Cancellation Date -->\n            <div>\n                <label for=\"cancellation_date\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Cancellation Date</label>\n                <input type=\"date\" id=\"cancellation_date\" name=\"cancellation_date\"\n                       value=\"{{if .Subscription}}{{if .Subscription.CancellationDate}}{{.Subscription.CancellationDate.Format \"2006-01-02\"}}{{end}}{{end}}\"\n                       class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n            </div>\n\n            <!-- Usage -->\n            <div>\n                <label for=\"usage\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Usage Level</label>\n                <select id=\"usage\" name=\"usage\"\n                        class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">\n                    <option value=\"\">Select usage</option>\n                    <option value=\"High\" {{if .Subscription}}{{if eq .Subscription.Usage \"High\"}}selected{{end}}{{end}}>High</option>\n                    <option value=\"Medium\" {{if .Subscription}}{{if eq .Subscription.Usage \"Medium\"}}selected{{end}}{{end}}>Medium</option>\n                    <option value=\"Low\" {{if .Subscription}}{{if eq .Subscription.Usage \"Low\"}}selected{{end}}{{end}}>Low</option>\n                    <option value=\"None\" {{if .Subscription}}{{if eq .Subscription.Usage \"None\"}}selected{{end}}{{end}}>None</option>\n                </select>\n            </div>\n\n            <!-- Notes -->\n            <div class=\"md:col-span-2\">\n                <label for=\"notes\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">Notes</label>\n                <textarea id=\"notes\" name=\"notes\" rows=\"3\"\n                          placeholder=\"Additional notes about this subscription\"\n                          class=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 transition-colors duration-150\">{{if .Subscription}}{{.Subscription.Notes}}{{end}}</textarea>\n            </div>\n\n            <!-- Reminder Toggle -->\n            <div class=\"md:col-span-2\">\n                <label class=\"flex items-center space-x-3 cursor-pointer\">\n                    <input type=\"hidden\" name=\"reminder_enabled\" value=\"false\">\n                    <input type=\"checkbox\" name=\"reminder_enabled\" value=\"true\"\n                           {{if .IsEdit}}{{if .Subscription.ReminderEnabled}}checked{{end}}{{else}}checked{{end}}\n                           class=\"w-4 h-4 text-primary bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-primary focus:ring-2 transition-colors duration-150\">\n                    <span class=\"text-sm font-medium text-gray-700 dark:text-gray-300\">Send renewal reminders for this subscription</span>\n                </label>\n                <p class=\"mt-1 text-xs text-gray-500 dark:text-gray-400 ml-7\">Disable for autopay subscriptions that don't need reminders</p>\n            </div>\n        </div>\n\n        <div class=\"flex justify-end space-x-3 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700\">\n            <button type=\"button\" onclick=\"document.getElementById('modal').classList.add('hidden')\"\n                    class=\"px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors duration-150\">\n                Cancel\n            </button>\n            <button type=\"submit\"\n                    class=\"px-4 py-2 text-sm font-medium text-white bg-primary border border-transparent rounded-lg hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n                {{if .IsEdit}}Update{{else}}Add{{end}} Subscription\n            </button>\n        </div>\n    </form>\n</div>\n\n<script>\n// Inline category creation functions\nfunction showNewCategoryInput() {\n    document.getElementById('new-category-container').classList.remove('hidden');\n    document.getElementById('add-category-btn').classList.add('hidden');\n    document.getElementById('new-category-name').focus();\n}\n\nfunction hideNewCategoryInput() {\n    document.getElementById('new-category-container').classList.add('hidden');\n    document.getElementById('add-category-btn').classList.remove('hidden');\n    document.getElementById('new-category-name').value = '';\n    document.getElementById('new-category-error').classList.add('hidden');\n}\n\nlet isCreatingCategory = false;\n\nasync function createNewCategory() {\n    // Prevent double-submission\n    if (isCreatingCategory) return;\n\n    const nameInput = document.getElementById('new-category-name');\n    const errorDiv = document.getElementById('new-category-error');\n    const addBtn = document.querySelector('#new-category-container button');\n    const name = nameInput.value.trim();\n\n    if (!name) {\n        errorDiv.textContent = 'Please enter a category name';\n        errorDiv.classList.remove('hidden');\n        return;\n    }\n\n    isCreatingCategory = true;\n    if (addBtn) addBtn.disabled = true;\n\n    try {\n        const response = await fetch('/api/categories', {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n            },\n            body: JSON.stringify({ name: name }),\n        });\n\n        if (!response.ok) {\n            const data = await response.json();\n            throw new Error(data.error || 'Failed to create category');\n        }\n\n        const newCategory = await response.json();\n\n        // Validate response before adding\n        if (!newCategory.id || !newCategory.name) {\n            throw new Error('Invalid response from server');\n        }\n\n        // Add new option to dropdown and select it\n        const select = document.getElementById('category_id');\n        const option = document.createElement('option');\n        option.value = newCategory.id;\n        option.textContent = newCategory.name;\n        option.selected = true;\n        select.appendChild(option);\n\n        // Hide the input and reset\n        hideNewCategoryInput();\n    } catch (error) {\n        errorDiv.textContent = error.message;\n        errorDiv.classList.remove('hidden');\n    } finally {\n        isCreatingCategory = false;\n        if (addBtn) addBtn.disabled = false;\n    }\n}\n\n// Allow Enter key to submit new category\ndocument.addEventListener('keydown', function(e) {\n    if (e.key === 'Enter' && document.activeElement.id === 'new-category-name') {\n        e.preventDefault();\n        createNewCategory();\n    }\n});\n\nfunction updateScheduleFields() {\n    const combo = document.getElementById('schedule_combo');\n    const scheduleInput = document.getElementById('schedule');\n    const intervalInput = document.getElementById('schedule_interval');\n    if (!combo.value) return;\n    const parts = combo.value.split('_');\n    scheduleInput.value = parts[0];\n    intervalInput.value = parts[1] || '1';\n    calculateRenewalDate();\n}\n\nfunction initScheduleCombo() {\n    const combo = document.getElementById('schedule_combo');\n    const schedule = document.getElementById('schedule').value;\n    const interval = (parseInt(document.getElementById('schedule_interval').value) || 1).toString();\n    if (schedule) {\n        const comboVal = schedule + '_' + interval;\n        for (const opt of combo.options) {\n            if (opt.value === comboVal) {\n                opt.selected = true;\n                break;\n            }\n        }\n    }\n}\n\nfunction addMonths(date, months) {\n    const d = new Date(date);\n    const targetMonth = d.getMonth() + months;\n    const targetYear = d.getFullYear() + Math.floor(targetMonth / 12);\n    const normalizedMonth = ((targetMonth % 12) + 12) % 12;\n    const lastDay = new Date(targetYear, normalizedMonth + 1, 0).getDate();\n    const day = Math.min(d.getDate(), lastDay);\n    return new Date(targetYear, normalizedMonth, day);\n}\n\nfunction calculateRenewalDate() {\n    const schedule = document.getElementById('schedule').value;\n    const interval = parseInt(document.getElementById('schedule_interval').value) || 1;\n    const renewalDateInput = document.getElementById('renewal_date');\n    if (!schedule) return;\n\n    const today = new Date();\n    let renewalDate;\n\n    switch (schedule) {\n        case 'Daily':\n            renewalDate = new Date(today);\n            renewalDate.setDate(today.getDate() + interval);\n            break;\n        case 'Weekly':\n            renewalDate = new Date(today);\n            renewalDate.setDate(today.getDate() + 7 * interval);\n            break;\n        case 'Monthly':\n            renewalDate = addMonths(today, interval);\n            break;\n        case 'Quarterly':\n            renewalDate = addMonths(today, 3 * interval);\n            break;\n        case 'Annual':\n            renewalDate = new Date(today);\n            renewalDate.setFullYear(today.getFullYear() + interval);\n            break;\n        default:\n            return;\n    }\n\n    const year = renewalDate.getFullYear();\n    const month = String(renewalDate.getMonth() + 1).padStart(2, '0');\n    const day = String(renewalDate.getDate()).padStart(2, '0');\n    renewalDateInput.value = `${year}-${month}-${day}`;\n}\n\nfunction initRenewalCalculator() {\n    initScheduleCombo();\n}\n\ninitRenewalCalculator();\n\n// Also initialize on DOM content loaded as backup\ndocument.addEventListener('DOMContentLoaded', initRenewalCalculator);\n</script>"
  },
  {
    "path": "templates/subscription-list.html",
    "content": "<div id=\"subscription-list\" class=\"overflow-x-auto\">\n    {{if .Subscriptions}}\n    <table class=\"min-w-full divide-y divide-gray-200 dark:divide-gray-700\">\n        <thead class=\"bg-gray-50 dark:bg-gray-800\">\n            <tr>\n                <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                    <button \n                        hx-get=\"/api/subscriptions?sort=name&order={{if and (eq .SortBy \"name\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                        hx-target=\"#subscription-list\"\n                        hx-swap=\"outerHTML\"\n                        class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                        <span>Name</span>\n                        {{if eq .SortBy \"name\"}}\n                        <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{else}}\n                        <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{end}}\n                    </button>\n                </th>\n                <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                    <button \n                        hx-get=\"/api/subscriptions?sort=category&order={{if and (eq .SortBy \"category\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                        hx-target=\"#subscription-list\"\n                        hx-swap=\"outerHTML\"\n                        class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                        <span>Category</span>\n                        {{if eq .SortBy \"category\"}}\n                        <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{else}}\n                        <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{end}}\n                    </button>\n                </th>\n                <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                    <button \n                        hx-get=\"/api/subscriptions?sort=cost&order={{if and (eq .SortBy \"cost\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                        hx-target=\"#subscription-list\"\n                        hx-swap=\"outerHTML\"\n                        class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                        <span>Cost</span>\n                        {{if eq .SortBy \"cost\"}}\n                        <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{else}}\n                        <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{end}}\n                    </button>\n                </th>\n                <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                    <button \n                        hx-get=\"/api/subscriptions?sort=schedule&order={{if and (eq .SortBy \"schedule\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                        hx-target=\"#subscription-list\"\n                        hx-swap=\"outerHTML\"\n                        class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                        <span>Schedule</span>\n                        {{if eq .SortBy \"schedule\"}}\n                        <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{else}}\n                        <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{end}}\n                    </button>\n                </th>\n                <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                    <button \n                        hx-get=\"/api/subscriptions?sort=status&order={{if and (eq .SortBy \"status\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                        hx-target=\"#subscription-list\"\n                        hx-swap=\"outerHTML\"\n                        class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                        <span>Status</span>\n                        {{if eq .SortBy \"status\"}}\n                        <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{else}}\n                        <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{end}}\n                    </button>\n                </th>\n                <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                    <button \n                        hx-get=\"/api/subscriptions?sort=renewal_date&order={{if and (eq .SortBy \"renewal_date\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                        hx-target=\"#subscription-list\"\n                        hx-swap=\"outerHTML\"\n                        class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                        <span>Renewal Date</span>\n                        {{if eq .SortBy \"renewal_date\"}}\n                        <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{else}}\n                        <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                        </svg>\n                        {{end}}\n                    </button>\n                </th>\n                <th scope=\"col\" class=\"px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                    Actions\n                </th>\n            </tr>\n        </thead>\n        <tbody class=\"bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700\">\n            {{range .Subscriptions}}\n            <tr class=\"hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150\">\n                <td class=\"px-6 py-4 whitespace-nowrap\">\n                    <div class=\"flex items-center\">\n                        {{if .IconURL}}\n                        <img src=\"{{.IconURL}}\" alt=\"{{.Name}}\" class=\"w-8 h-8 rounded mr-3 flex-shrink-0\" onerror=\"this.style.display='none'; this.nextElementSibling.style.display='block';\" style=\"object-fit: contain;\">\n                        <div class=\"w-3 h-3 {{if eq .Status \"Active\"}}bg-success{{else if eq .Status \"Cancelled\"}}bg-danger{{else}}bg-warning{{end}} rounded-full mr-3\" style=\"display:none;\"></div>\n                        {{else}}\n                        <div class=\"w-3 h-3 {{if eq .Status \"Active\"}}bg-success{{else if eq .Status \"Cancelled\"}}bg-danger{{else}}bg-warning{{end}} rounded-full mr-3\"></div>\n                        {{end}}\n                        <div>\n                            <div class=\"text-sm font-medium text-gray-900 dark:text-white\">{{.Name}}{{if not .ReminderEnabled}} <span title=\"Reminders disabled\" class=\"inline-flex text-gray-400 dark:text-gray-500\"><svg class=\"w-3.5 h-3.5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/><line x1=\"3\" y1=\"3\" x2=\"21\" y2=\"21\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg></span>{{end}}</div>\n                            {{if .URL}}\n                            <a href=\"{{.URL}}\" target=\"_blank\" class=\"text-xs text-primary hover:text-primary/80 dark:text-primary-light\">{{.URL}}</a>\n                            {{end}}\n                        </div>\n                    </div>\n                </td>\n                <td class=\"px-6 py-4 whitespace-nowrap\">\n                    <div class=\"text-sm text-gray-900 dark:text-white\">{{.Category.Name}}</div>\n                </td>\n                <td class=\"px-6 py-4 whitespace-nowrap\">\n                    {{if .ShowConversion}}\n                    <div class=\"text-sm font-medium text-gray-900 dark:text-white\">{{.DisplayCurrencySymbol}}{{printf \"%.2f\" .ConvertedCost}}</div>\n                    <a href=\"https://fixer.io\" target=\"_blank\" rel=\"noopener\"\n                       class=\"text-xs text-gray-500 dark:text-gray-400 hover:text-primary flex items-center gap-1\"\n                       title=\"Original amount before conversion (rates from Fixer.io)\">\n                        {{.OriginalCurrency}} {{printf \"%.2f\" .Cost}}\n                        <svg class=\"w-3 h-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                        </svg>\n                    </a>\n                    {{else}}\n                    <div class=\"text-sm font-medium text-gray-900 dark:text-white\">{{.DisplayCurrencySymbol}}{{printf \"%.2f\" .Cost}}</div>\n                    {{end}}\n                </td>\n                <td class=\"px-6 py-4 whitespace-nowrap\">\n                    <div class=\"text-sm text-gray-900 dark:text-white\">{{.DisplaySchedule}}</div>\n                </td>\n                <td class=\"px-6 py-4 whitespace-nowrap\">\n                    <span class=\"px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{if eq .Status \"Active\"}}bg-success/20 text-success{{else if eq .Status \"Cancelled\"}}bg-danger/20 text-danger{{else}}bg-warning/20 text-warning{{end}}\">\n                        {{.Status}}\n                    </span>\n                </td>\n                <td class=\"px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400\">\n                    {{if .RenewalDate}}\n                    {{fmtDate .RenewalDate $.GoDateFormat}}\n                    {{else}}\n                    <span class=\"text-gray-400 dark:text-gray-500\">—</span>\n                    {{end}}\n                </td>\n                <td class=\"px-6 py-4 whitespace-nowrap text-right text-sm font-medium\">\n                    <div class=\"flex items-center justify-end space-x-2\">\n                        {{if .Notes}}\n                        <div class=\"relative group\">\n                            <button \n                                class=\"text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150\"\n                                aria-label=\"View note\"\n                                aria-describedby=\"note-tooltip-{{.ID}}\"\n                                title=\"View note\">\n                                <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"></path>\n                                </svg>\n                            </button>\n                            <div id=\"note-tooltip-{{.ID}}\" class=\"absolute right-0 bottom-full mb-2 w-auto min-w-0 p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200 z-10 whitespace-nowrap\">\n                                <p>{{.Notes}}</p>\n                                <div class=\"absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700\"></div>\n                            </div>\n                        </div>\n                        {{end}}\n                        <button \n                            hx-get=\"/form/subscription/{{.ID}}\"\n                            hx-target=\"#modal-content\"\n                            hx-trigger=\"click\"\n                            onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                            class=\"text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150\"\n                            title=\"Edit\">\n                            <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"></path>\n                            </svg>\n                        </button>\n                        <button \n                            hx-delete=\"/api/subscriptions/{{.ID}}\"\n                            hx-confirm=\"Are you sure you want to delete this subscription?\"\n                            hx-target=\"#subscription-list\"\n                            hx-swap=\"outerHTML\"\n                            class=\"text-gray-400 dark:text-gray-500 hover:text-danger dark:hover:text-red-400 transition-colors duration-150\"\n                            title=\"Delete\">\n                            <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"></path>\n                            </svg>\n                        </button>\n                    </div>\n                </td>\n            </tr>\n            {{end}}\n        </tbody>\n    </table>\n    {{else}}\n    <div class=\"p-12 text-center\">\n        <svg class=\"w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n        </svg>\n        <p class=\"text-gray-500 dark:text-gray-400 text-sm mb-4\">No subscriptions yet</p>\n        <button \n            hx-get=\"/form/subscription\"\n            hx-target=\"#modal-content\"\n            hx-trigger=\"click\"\n            onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n            class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n            Add your first subscription\n        </button>\n    </div>\n    {{end}}\n</div>\n"
  },
  {
    "path": "templates/subscriptions.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <script src=\"/static/js/theme-init.js\"></script>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"theme-color\" content=\"#37889b\">\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <link rel=\"apple-touch-icon\" href=\"/static/images/apple-touch-icon.png\">\n    <link rel=\"manifest\" href=\"/manifest.json\">\n    <title>{{.Title}} - SubTrackr</title>\n    <script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script>\n        tailwind.config = {\n            darkMode: 'class',\n            theme: {\n                extend: {\n                    colors: {\n                        'primary': '#3b82f6',\n                        'success': '#10b981',\n                        'warning': '#f59e0b',\n                        'danger': '#ef4444',\n                    }\n                }\n            }\n        }\n    </script>\n    <link rel=\"stylesheet\" href=\"/static/css/themes.css\">\n    <script src=\"/static/js/themes.js\"></script>\n    <script src=\"/static/js/mobile-menu.js\"></script>\n    <script src=\"/static/js/sorting.js\"></script>\n</head>\n<body class=\"bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200\">\n    <div class=\"flex flex-col min-h-screen\">\n        <!-- Header -->\n        <header class=\"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 transition-colors duration-200\">\n            <div class=\"flex items-center justify-between max-w-7xl mx-auto\">\n                <div class=\"flex items-center space-x-4 md:space-x-8\">\n                    <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                    <!-- Desktop Navigation -->\n                    <nav class=\"hidden md:flex space-x-1\">\n                        <a href=\"/\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" class=\"flex items-center px-3 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                    </nav>\n                    <!-- Mobile Hamburger Button -->\n                    <button id=\"mobile-menu-button\" class=\"md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Open menu\">\n                        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 12h16M4 18h16\"></path>\n                        </svg>\n                    </button>\n                </div>\n                <!-- Desktop Actions -->\n                <div class=\"hidden md:flex items-center space-x-3\">\n                    <button \n                        hx-get=\"/form/subscription\"\n                        hx-target=\"#modal-content\"\n                        hx-trigger=\"click\"\n                        onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                        class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 flex items-center transition-colors duration-150\">\n                        <svg class=\"w-4 h-4 mr-1\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                        </svg>\n                        Add\n                    </button>\n                    <a href=\"/settings\" class=\"text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-150\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n                <!-- Mobile Actions (Settings only, Add is in menu) -->\n                <div class=\"md:hidden flex items-center\">\n                    <a href=\"/settings\" class=\"p-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-150\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </a>\n                </div>\n            </div>\n        </header>\n\n        <!-- Mobile Menu Overlay -->\n        <div id=\"mobile-menu\" class=\"hidden fixed inset-0 z-50 md:hidden\">\n            <!-- Backdrop -->\n            <div class=\"fixed inset-0 bg-black bg-opacity-50 transition-opacity\" onclick=\"closeMobileMenu()\"></div>\n            <!-- Menu Panel -->\n            <div class=\"fixed left-0 top-0 bottom-0 w-64 bg-white dark:bg-gray-800 shadow-xl transform transition-transform duration-300 ease-in-out\">\n                <div class=\"flex flex-col h-full\">\n                    <!-- Menu Header -->\n                    <div class=\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700\">\n                        <img src=\"/static/images/logo.svg\" alt=\"SubTrackr\" class=\"h-8 w-auto\">\n                        <button onclick=\"closeMobileMenu()\" class=\"p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\" aria-label=\"Close menu\">\n                            <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                            </svg>\n                        </button>\n                    </div>\n                    <!-- Menu Items -->\n                    <nav class=\"flex-1 overflow-y-auto p-4 space-y-2\">\n                        <a href=\"/\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z\"></path>\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5a2 2 0 012-2h4a2 2 0 012 2v3H8V5z\"></path>\n                            </svg>\n                            Dashboard\n                        </a>\n                        <a href=\"/subscriptions\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n                            </svg>\n                            Subscriptions\n                        </a>\n                        <a href=\"/analytics\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\"></path>\n                            </svg>\n                            Analytics\n                        </a>\n                        <a href=\"/calendar\" onclick=\"closeMobileMenu()\" class=\"flex items-center px-4 py-3 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-150\">\n                            <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"></path>\n                            </svg>\n                            Calendar\n                        </a>\n                        <div class=\"pt-4 border-t border-gray-200 dark:border-gray-700 mt-4\">\n                            <button \n                                onclick=\"closeMobileMenuAndThen(function() { document.getElementById('modal').classList.remove('hidden'); });\"\n                                hx-get=\"/form/subscription\"\n                                hx-target=\"#modal-content\"\n                                hx-trigger=\"click\"\n                                class=\"w-full flex items-center justify-center px-4 py-3 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n                                <svg class=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                                </svg>\n                                Add Subscription\n                            </button>\n                        </div>\n                    </nav>\n                </div>\n            </div>\n        </div>\n\n        <!-- Main Content -->\n        <main class=\"flex-1 p-4\">\n            <div class=\"max-w-7xl mx-auto\">\n\n<div class=\"bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 transition-colors duration-200\">\n    <div class=\"p-6 border-b border-gray-200 dark:border-gray-700\">\n        <div class=\"flex items-center justify-between\">\n            <h2 class=\"text-lg font-semibold text-gray-900 dark:text-white\">Subscriptions</h2>\n            <button \n                hx-get=\"/form/subscription\"\n                hx-target=\"#modal-content\"\n                hx-trigger=\"click\"\n                onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 flex items-center transition-colors duration-150\">\n                <svg class=\"w-4 h-4 mr-1\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path>\n                </svg>\n                Add Subscription\n            </button>\n        </div>\n    </div>\n    \n    <div id=\"subscription-list\" class=\"overflow-x-auto\">\n        {{if .Subscriptions}}\n        <table class=\"min-w-full divide-y divide-gray-200 dark:divide-gray-700\">\n            <thead class=\"bg-gray-50 dark:bg-gray-800\">\n                <tr>\n                    <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                        <button \n                            hx-get=\"/api/subscriptions?sort=name&order={{if and (eq .SortBy \"name\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                            hx-target=\"#subscription-list\"\n                            hx-swap=\"outerHTML\"\n                            class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                            <span>Name</span>\n                            {{if eq .SortBy \"name\"}}\n                            <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{else}}\n                            <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{end}}\n                        </button>\n                    </th>\n                    <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                        <button \n                            hx-get=\"/api/subscriptions?sort=category&order={{if and (eq .SortBy \"category\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                            hx-target=\"#subscription-list\"\n                            hx-swap=\"outerHTML\"\n                            class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                            <span>Category</span>\n                            {{if eq .SortBy \"category\"}}\n                            <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{else}}\n                            <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{end}}\n                        </button>\n                    </th>\n                    <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                        <button \n                            hx-get=\"/api/subscriptions?sort=cost&order={{if and (eq .SortBy \"cost\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                            hx-target=\"#subscription-list\"\n                            hx-swap=\"outerHTML\"\n                            class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                            <span>Cost</span>\n                            {{if eq .SortBy \"cost\"}}\n                            <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{else}}\n                            <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{end}}\n                        </button>\n                    </th>\n                    <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                        <button \n                            hx-get=\"/api/subscriptions?sort=schedule&order={{if and (eq .SortBy \"schedule\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                            hx-target=\"#subscription-list\"\n                            hx-swap=\"outerHTML\"\n                            class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                            <span>Schedule</span>\n                            {{if eq .SortBy \"schedule\"}}\n                            <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{else}}\n                            <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{end}}\n                        </button>\n                    </th>\n                    <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                        <button \n                            hx-get=\"/api/subscriptions?sort=status&order={{if and (eq .SortBy \"status\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                            hx-target=\"#subscription-list\"\n                            hx-swap=\"outerHTML\"\n                            class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                            <span>Status</span>\n                            {{if eq .SortBy \"status\"}}\n                            <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{else}}\n                            <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{end}}\n                        </button>\n                    </th>\n                    <th scope=\"col\" class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                        <button \n                            hx-get=\"/api/subscriptions?sort=renewal_date&order={{if and (eq .SortBy \"renewal_date\") (eq .Order \"asc\")}}desc{{else}}asc{{end}}\"\n                            hx-target=\"#subscription-list\"\n                            hx-swap=\"outerHTML\"\n                            class=\"flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors\">\n                            <span>Renewal Date</span>\n                            {{if eq .SortBy \"renewal_date\"}}\n                            <svg class=\"w-4 h-4 {{if eq .Order \"asc\"}}transform rotate-180{{end}}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{else}}\n                            <svg class=\"w-4 h-4 opacity-30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>\n                            </svg>\n                            {{end}}\n                        </button>\n                    </th>\n                    <th scope=\"col\" class=\"px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider\">\n                        Actions\n                    </th>\n                </tr>\n            </thead>\n            <tbody class=\"bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700\">\n                {{range .Subscriptions}}\n                <tr class=\"hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150\">\n                    <td class=\"px-6 py-4 whitespace-nowrap\">\n                        <div class=\"flex items-center\">\n                            {{if .IconURL}}\n                            <img src=\"{{.IconURL}}\" alt=\"{{.Name}}\" class=\"w-8 h-8 rounded mr-3 flex-shrink-0\" onerror=\"this.style.display='none'; this.nextElementSibling.style.display='block';\" style=\"object-fit: contain;\">\n                            <div class=\"w-3 h-3 {{if eq .Status \"Active\"}}bg-success{{else if eq .Status \"Cancelled\"}}bg-danger{{else}}bg-warning{{end}} rounded-full mr-3\" style=\"display:none;\"></div>\n                            {{else}}\n                            <div class=\"w-3 h-3 {{if eq .Status \"Active\"}}bg-success{{else if eq .Status \"Cancelled\"}}bg-danger{{else}}bg-warning{{end}} rounded-full mr-3\"></div>\n                            {{end}}\n                            <div>\n                                <div class=\"text-sm font-medium text-gray-900 dark:text-white\">{{.Name}}</div>\n                                {{if .URL}}\n                                <a href=\"{{.URL}}\" target=\"_blank\" class=\"text-xs text-primary hover:text-primary/80 dark:text-primary-light\">{{.URL}}</a>\n                    {{end}}\n                </div>\n            </div>\n                    </td>\n                    <td class=\"px-6 py-4 whitespace-nowrap\">\n                        <div class=\"text-sm text-gray-900 dark:text-white\">{{.Category.Name}}</div>\n                    </td>\n                    <td class=\"px-6 py-4 whitespace-nowrap\">\n                        {{if .ShowConversion}}\n                        <div class=\"text-sm font-medium text-gray-900 dark:text-white\">{{.DisplayCurrencySymbol}}{{printf \"%.2f\" .ConvertedCost}}</div>\n                        <a href=\"https://fixer.io\" target=\"_blank\" rel=\"noopener\"\n                           class=\"text-xs text-gray-500 dark:text-gray-400 hover:text-primary flex items-center gap-1\"\n                           title=\"Original amount before conversion (rates from Fixer.io)\">\n                            {{.OriginalCurrency}} {{printf \"%.2f\" .Cost}}\n                            <svg class=\"w-3 h-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                            </svg>\n                        </a>\n                        {{else}}\n                        <div class=\"text-sm font-medium text-gray-900 dark:text-white\">{{$.CurrencySymbol}}{{printf \"%.2f\" .Cost}}</div>\n                        {{end}}\n                    </td>\n                    <td class=\"px-6 py-4 whitespace-nowrap\">\n                        <div class=\"text-sm text-gray-900 dark:text-white\">{{.DisplaySchedule}}</div>\n                    </td>\n                    <td class=\"px-6 py-4 whitespace-nowrap\">\n                        <span class=\"px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{if eq .Status \"Active\"}}bg-success/20 text-success{{else if eq .Status \"Cancelled\"}}bg-danger/20 text-danger{{else}}bg-warning/20 text-warning{{end}}\">\n                            {{.Status}}\n                        </span>\n                    </td>\n                    <td class=\"px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400\">\n                        {{if .RenewalDate}}\n                        {{fmtDate .RenewalDate $.GoDateFormat}}\n                        {{else}}\n                        <span class=\"text-gray-400 dark:text-gray-500\">—</span>\n                        {{end}}\n                    </td>\n                    <td class=\"px-6 py-4 whitespace-nowrap text-right text-sm font-medium\">\n                        <div class=\"flex items-center justify-end space-x-2\">\n                            {{if .Notes}}\n                            <div class=\"relative group\">\n                                <button \n                                    class=\"text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150\"\n                                    aria-label=\"View note\"\n                                    aria-describedby=\"note-tooltip-{{.ID}}\"\n                                    title=\"View note\">\n                                    <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"></path>\n                                    </svg>\n                                </button>\n                                <div id=\"note-tooltip-{{.ID}}\" class=\"absolute right-0 bottom-full mb-2 w-auto min-w-0 max-w-xs p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200 z-10\">\n                                    <p>{{.Notes}}</p>\n                                    <div class=\"absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700\"></div>\n                                </div>\n                            </div>\n                            {{end}}\n                            <button \n                                hx-get=\"/form/subscription/{{.ID}}\"\n                                hx-target=\"#modal-content\"\n                                hx-trigger=\"click\"\n                                onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                                class=\"text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150\"\n                                title=\"Edit\">\n                                <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"></path>\n                                </svg>\n                            </button>\n                            <button \n                                hx-delete=\"/api/subscriptions/{{.ID}}\"\n                                hx-confirm=\"Are you sure you want to delete this subscription?\"\n                                hx-target=\"#subscription-list\"\n                                hx-swap=\"outerHTML\"\n                                class=\"text-gray-400 dark:text-gray-500 hover:text-danger dark:hover:text-red-400 transition-colors duration-150\"\n                                title=\"Delete\">\n                                <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"></path>\n                                </svg>\n                            </button>\n                        </div>\n                    </td>\n                </tr>\n                {{end}}\n            </tbody>\n        </table>\n    {{else}}\n    <div class=\"p-12 text-center\">\n            <svg class=\"w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"></path>\n        </svg>\n            <p class=\"text-gray-500 dark:text-gray-400 text-sm mb-4\">No subscriptions yet</p>\n        <button \n            hx-get=\"/form/subscription\"\n            hx-target=\"#modal-content\"\n            hx-trigger=\"click\"\n            onclick=\"document.getElementById('modal').classList.remove('hidden')\"\n                class=\"bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 transition-colors duration-150\">\n            Add your first subscription\n        </button>\n    </div>\n    {{end}}\n    </div>\n</div>\n\n            </div>\n        </main>\n    </div>\n\n    <!-- Modal -->\n    <div id=\"modal\" class=\"hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50\">\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4 transition-colors duration-200\">\n            <div id=\"modal-content\">\n                <!-- Dynamic content loaded here -->\n            </div>\n        </div>\n    </div>\n\n    <script>\n        // Close modal when clicking outside\n        document.getElementById('modal').addEventListener('click', function(e) {\n            if (e.target === this) {\n                this.classList.add('hidden');\n            }\n        });\n    </script>\n</body>\n</html>"
  },
  {
    "path": "test-api.sh",
    "content": "#!/bin/bash\n\n# SubTrackr API Test Script\n# This script demonstrates how to use the SubTrackr API with authentication\n\nAPI_KEY=\"sk_your_api_key_here\"  # Replace with your actual API key\nBASE_URL=\"http://localhost:8080\"\n\necho \"SubTrackr API Test Script\"\necho \"========================\"\necho \"\"\necho \"Make sure to:\"\necho \"1. Start the SubTrackr server (go run cmd/server/main.go)\"\necho \"2. Create an API key from the Settings page\"\necho \"3. Replace the API_KEY variable in this script with your actual key\"\necho \"\"\necho \"Press Enter to continue...\"\nread\n\n# Test 1: Get all subscriptions\necho \"Test 1: Getting all subscriptions...\"\ncurl -s -H \"Authorization: Bearer $API_KEY\" \\\n  \"$BASE_URL/api/v1/subscriptions\" | jq .\n\necho \"\"\necho \"Press Enter to continue...\"\nread\n\n# Test 2: Get statistics\necho \"Test 2: Getting statistics...\"\ncurl -s -H \"Authorization: Bearer $API_KEY\" \\\n  \"$BASE_URL/api/v1/stats\" | jq .\n\necho \"\"\necho \"Press Enter to continue...\"\nread\n\n# Test 3: Create a new subscription\necho \"Test 3: Creating a new subscription...\"\n# Note: You'll need to replace category_id with an actual ID from your categories\n# You can get the list of categories with: curl -s \"$BASE_URL/api/categories\" | jq .\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"Test Subscription\",\n    \"cost\": 9.99,\n    \"schedule\": \"Monthly\",\n    \"status\": \"Active\",\n    \"category_id\": 1\n  }' \\\n  \"$BASE_URL/api/v1/subscriptions\" | jq .\n\necho \"\"\necho \"Press Enter to continue...\"\nread\n\n# Test 4: Export as JSON\necho \"Test 4: Exporting as JSON...\"\ncurl -s -H \"Authorization: Bearer $API_KEY\" \\\n  \"$BASE_URL/api/v1/export/json\" | jq .\n\necho \"\"\necho \"Test complete!\""
  },
  {
    "path": "tests/example.spec.js",
    "content": "// @ts-check\nconst { test, expect } = require('@playwright/test');\n\ntest('has title', async ({ page }) => {\n  await page.goto('/');\n\n  // Expect a title \"to contain\" a substring.\n  await expect(page).toHaveTitle(/SubTrackr/);\n});\n\ntest('can navigate to subscriptions', async ({ page }) => {\n  await page.goto('/');\n\n  // Click the subscriptions link.\n  await page.click('a[href=\"/subscriptions\"]');\n\n  // Expects page to have a heading with the name of subscriptions.\n  await expect(page.getByRole('heading', { name: 'Subscriptions' })).toBeVisible();\n});"
  },
  {
    "path": "tests/subscription-crud.spec.js",
    "content": "// @ts-check\nconst { test, expect } = require('@playwright/test');\n\ntest.describe('Subscription CRUD Operations', () => {\n  test('can create a new subscription', async ({ page }) => {\n    await page.goto('/subscriptions');\n\n    // Click Add Subscription button\n    await page.click('button:has-text(\"Add Subscription\")');\n\n    // Fill out the form\n    await page.fill('input[name=\"name\"]', 'Test Subscription');\n    await page.fill('input[name=\"cost\"]', '9.99');\n    await page.selectOption('select[name=\"billing_cycle\"]', 'Monthly');\n    await page.selectOption('select[name=\"status\"]', 'Active');\n\n    // Submit the form\n    await page.click('button[type=\"submit\"]');\n\n    // Wait for page reload and check if subscription appears\n    await page.waitForLoadState('networkidle');\n    await expect(page.getByText('Test Subscription')).toBeVisible();\n    await expect(page.getByText('$9.99')).toBeVisible();\n  });\n\n  test('can edit an existing subscription', async ({ page }) => {\n    await page.goto('/subscriptions');\n\n    // Assuming there's at least one subscription from the previous test\n    // Click the first edit button\n    await page.click('button:has-text(\"Edit\"):first-of-type');\n\n    // Modify the name\n    await page.fill('input[name=\"name\"]', 'Updated Test Subscription');\n    await page.fill('input[name=\"cost\"]', '14.99');\n\n    // Submit the form\n    await page.click('button[type=\"submit\"]');\n\n    // Wait for page reload and check if changes are saved\n    await page.waitForLoadState('networkidle');\n    await expect(page.getByText('Updated Test Subscription')).toBeVisible();\n    await expect(page.getByText('$14.99')).toBeVisible();\n  });\n\n  test('displays correct currency formatting', async ({ page }) => {\n    await page.goto('/subscriptions');\n\n    // Check that all prices end with .00 or have proper decimal formatting\n    const priceElements = await page.locator('[data-testid=\"subscription-cost\"], .text-sm.font-medium.text-gray-900').all();\n    \n    for (const element of priceElements) {\n      const text = await element.textContent();\n      if (text && text.includes('$')) {\n        // Should match format like $9.99 or $10.00\n        expect(text).toMatch(/\\$\\d+\\.\\d{2}/);\n      }\n    }\n  });\n\n  test('annual totals calculation is correct', async ({ page }) => {\n    await page.goto('/');\n\n    // Get the annual total from dashboard\n    const annualTotalElement = page.locator('[data-testid=\"annual-total\"]');\n    if (await annualTotalElement.count() > 0) {\n      const annualTotal = await annualTotalElement.textContent();\n      \n      // Navigate to subscriptions and calculate expected total\n      await page.goto('/subscriptions');\n      \n      const subscriptionElements = await page.locator('[data-testid=\"subscription-row\"]').all();\n      let expectedTotal = 0;\n      \n      for (const row of subscriptionElements) {\n        const costText = await row.locator('[data-testid=\"subscription-cost\"]').textContent();\n        const billingCycleText = await row.locator('[data-testid=\"billing-cycle\"]').textContent();\n        \n        if (costText && billingCycleText) {\n          const cost = parseFloat(costText.replace('$', ''));\n          let annualCost = cost;\n          \n          if (billingCycleText.includes('Monthly')) {\n            annualCost = cost * 12;\n          } else if (billingCycleText.includes('Weekly')) {\n            annualCost = cost * 52;\n          } else if (billingCycleText.includes('Daily')) {\n            annualCost = cost * 365;\n          }\n          \n          expectedTotal += annualCost;\n        }\n      }\n      \n      // Compare with actual total (allowing for small floating point differences)\n      const actualTotal = parseFloat(annualTotal?.replace('$', '') || '0');\n      expect(Math.abs(actualTotal - expectedTotal)).toBeLessThan(0.01);\n    }\n  });\n});"
  },
  {
    "path": "web/static/category-management.js",
    "content": "function startEditCategory(id) {\n    document.getElementById(`edit-category-form-${id}`).classList.remove('hidden');\n    document.getElementById(`category-name-${id}`).classList.add('hidden');\n    document.getElementById(`edit-btn-${id}`).classList.add('hidden');\n}\nfunction cancelEditCategory(id) {\n    document.getElementById(`edit-category-form-${id}`).classList.add('hidden');\n    document.getElementById(`category-name-${id}`).classList.remove('hidden');\n    document.getElementById(`edit-btn-${id}`).classList.remove('hidden');\n} "
  },
  {
    "path": "web/static/css/themes.css",
    "content": "/* SubTrackr Theme System Styles */\n\n:root {\n    /* Default theme colors (will be overridden by theme selection) */\n    --theme-primary: #3b82f6;\n    --theme-primaryHover: #2563eb;\n    --theme-secondary: #64748b;\n    --theme-success: #10b981;\n    --theme-warning: #f59e0b;\n    --theme-danger: #ef4444;\n    --theme-background: #f9fafb;\n    --theme-surface: #ffffff;\n    --theme-surfaceHover: #f3f4f6;\n    --theme-text: #111827;\n    --theme-textSecondary: #6b7280;\n    --theme-border: #e5e7eb;\n}\n\n/* Default Theme - Modern Light */\n[data-theme=\"default\"] body {\n    background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important;\n    color: #0f172a !important;\n}\n\n[data-theme=\"default\"] .bg-white,\n[data-theme=\"default\"] header {\n    background-color: #ffffff !important;\n    box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;\n}\n\n[data-theme=\"default\"] .bg-gray-50 {\n    background-color: #f8fafc !important;\n}\n\n[data-theme=\"default\"] .border-gray-200 {\n    border-color: #e2e8f0 !important;\n}\n\n/* Dark Theme - Professional Dark (not pure black) */\n[data-theme=\"dark\"] body {\n    background-color: #121212 !important;\n    color: #E4E4E7 !important;\n}\n\n[data-theme=\"dark\"] .bg-white,\n[data-theme=\"dark\"] header {\n    background-color: #1e1e1e !important;\n}\n\n[data-theme=\"dark\"] .bg-gray-50 {\n    background-color: #2a2a2a !important;\n}\n\n[data-theme=\"dark\"] .text-gray-900 {\n    color: #E4E4E7 !important;\n}\n\n[data-theme=\"dark\"] .text-gray-600,\n[data-theme=\"dark\"] .text-gray-700 {\n    color: #a1a1aa !important;\n}\n\n[data-theme=\"dark\"] .border-gray-200 {\n    border-color: #3f3f46 !important;\n}\n\n[data-theme=\"dark\"] .bg-primary,\n[data-theme=\"dark\"] button.bg-primary {\n    background-color: #60a5fa !important;\n}\n\n[data-theme=\"dark\"] .bg-primary:hover {\n    background-color: #3b82f6 !important;\n}\n\n/* Dark Theme - Hover States */\n[data-theme=\"dark\"] .hover\\:bg-gray-50:hover,\n[data-theme=\"dark\"] .hover\\:bg-gray-100:hover {\n    background-color: #3f3f46 !important;\n}\n\n[data-theme=\"dark\"] .hover\\:bg-white:hover {\n    background-color: #2a2a2a !important;\n}\n\n/* Dark Theme - Calendar Events */\n[data-theme=\"dark\"] .bg-blue-50 {\n    background-color: #1e293b !important;\n}\n\n[data-theme=\"dark\"] .bg-blue-100 {\n    background-color: #374151 !important;\n}\n\n[data-theme=\"dark\"] .text-blue-700 {\n    color: #93c5fd !important;\n}\n\n[data-theme=\"dark\"] .hover\\:bg-blue-200:hover {\n    background-color: #4b5563 !important;\n}\n\n/* Christmas Theme */\n[data-theme=\"christmas\"] body {\n    background: linear-gradient(135deg, #fef3f3 0%, #fef2f2 100%) !important;\n    color: #1f2937 !important;\n    background-image:\n        repeating-linear-gradient(\n            45deg,\n            transparent,\n            transparent 10px,\n            rgba(196, 30, 58, 0.02) 10px,\n            rgba(196, 30, 58, 0.02) 20px\n        );\n}\n\n[data-theme=\"christmas\"] .bg-white,\n[data-theme=\"christmas\"] header {\n    background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%) !important;\n    border-color: #fecaca !important;\n}\n\n[data-theme=\"christmas\"] .bg-primary,\n[data-theme=\"christmas\"] .bg-blue-600,\n[data-theme=\"christmas\"] button.bg-primary {\n    background-color: #c41e3a !important;\n}\n\n[data-theme=\"christmas\"] .bg-primary:hover,\n[data-theme=\"christmas\"] button.bg-primary:hover {\n    background-color: #a01729 !important;\n}\n\n[data-theme=\"christmas\"] .text-primary {\n    color: #c41e3a !important;\n}\n\n/* Midnight Theme - Deep Purple Dreams */\n[data-theme=\"midnight\"] body {\n    background: linear-gradient(135deg, #0a0a0f 0%, #1a0f2e 100%) !important;\n    color: #e9d5ff !important;\n}\n\n[data-theme=\"midnight\"] .bg-white,\n[data-theme=\"midnight\"] header {\n    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%) !important;\n    border-color: #4c1d95 !important;\n    box-shadow: 0 0 20px rgba(139, 92, 246, 0.15) !important;\n}\n\n[data-theme=\"midnight\"] .bg-gray-50 {\n    background-color: #1e1e2e !important;\n}\n\n[data-theme=\"midnight\"] .text-gray-900 {\n    color: #e9d5ff !important;\n}\n\n[data-theme=\"midnight\"] .text-gray-600,\n[data-theme=\"midnight\"] .text-gray-700 {\n    color: #c4b5fd !important;\n}\n\n[data-theme=\"midnight\"] .border-gray-200 {\n    border-color: #4c1d95 !important;\n}\n\n[data-theme=\"midnight\"] .bg-primary,\n[data-theme=\"midnight\"] button.bg-primary {\n    background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important;\n    box-shadow: 0 0 15px rgba(139, 92, 246, 0.5) !important;\n}\n\n[data-theme=\"midnight\"] .bg-primary:hover {\n    background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%) !important;\n    box-shadow: 0 0 25px rgba(139, 92, 246, 0.7) !important;\n}\n\n/* Midnight theme glow effects */\n[data-theme=\"midnight\"] h1,\n[data-theme=\"midnight\"] h2,\n[data-theme=\"midnight\"] h3 {\n    text-shadow: 0 0 10px rgba(139, 92, 246, 0.3);\n}\n\n/* Midnight Theme - Calendar Events */\n[data-theme=\"midnight\"] .bg-blue-50 {\n    background-color: #1e1e2e !important;\n}\n\n[data-theme=\"midnight\"] .bg-blue-100 {\n    background-color: #2a2540 !important;\n}\n\n[data-theme=\"midnight\"] .text-blue-700 {\n    color: #c4b5fd !important;\n}\n\n[data-theme=\"midnight\"] .hover\\:bg-blue-200:hover {\n    background-color: #3a3550 !important;\n}\n\n/* Ocean Theme */\n[data-theme=\"ocean\"] body {\n    background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%) !important;\n    color: #0c4a6e !important;\n}\n\n[data-theme=\"ocean\"] .bg-white,\n[data-theme=\"ocean\"] header {\n    background-color: #ffffff !important;\n    border-color: #bae6fd !important;\n}\n\n[data-theme=\"ocean\"] .text-gray-900 {\n    color: #0c4a6e !important;\n}\n\n[data-theme=\"ocean\"] .text-gray-600,\n[data-theme=\"ocean\"] .text-gray-700 {\n    color: #475569 !important;\n}\n\n[data-theme=\"ocean\"] .bg-primary,\n[data-theme=\"ocean\"] button.bg-primary {\n    background-color: #0891b2 !important;\n}\n\n[data-theme=\"ocean\"] .bg-primary:hover {\n    background-color: #06b6d4 !important;\n}\n\n/* Christmas Theme - Add festive touches */\n[data-theme=\"christmas\"] .logo,\n[data-theme=\"christmas\"] img[alt=\"SubTrackr\"] {\n    filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.3));\n}\n\n/* Christmas Theme - Festive buttons */\n[data-theme=\"christmas\"] button,\n[data-theme=\"christmas\"] .btn {\n    transition: all 0.3s ease;\n}\n\n[data-theme=\"christmas\"] button:hover,\n[data-theme=\"christmas\"] .btn:hover {\n    transform: scale(1.02);\n    box-shadow: 0 4px 12px rgba(196, 30, 58, 0.2);\n}\n\n/* Christmas Theme - Add gold sparkle to primary actions */\n[data-theme=\"christmas\"] .btn-primary::after {\n    content: '✨';\n    margin-left: 0.5rem;\n    opacity: 0;\n    transition: opacity 0.3s ease;\n}\n\n[data-theme=\"christmas\"] .btn-primary:hover::after {\n    opacity: 1;\n}\n\n/* Snowfall Animation */\n@keyframes snowfall {\n    0% {\n        transform: translateY(0) rotate(0deg);\n    }\n    100% {\n        transform: translateY(110vh) rotate(360deg);\n    }\n}\n\n.snowflake {\n    color: #ffffff;\n    text-shadow:\n        0 0 5px #ffffff,\n        0 0 10px #e0f2fe,\n        0 0 15px #bae6fd;\n}\n\n/* Christmas Theme - Festive card decorations */\n[data-theme=\"christmas\"] .card,\n[data-theme=\"christmas\"] .bg-white {\n    border-left: 3px solid var(--theme-special-accent, #ffd700);\n    box-shadow:\n        0 1px 3px rgba(0, 0, 0, 0.1),\n        -3px 0 0 rgba(255, 215, 0, 0.3);\n}\n\n/* Christmas Theme - Add holly decorations to headers */\n[data-theme=\"christmas\"] h1::before,\n[data-theme=\"christmas\"] h2::before {\n    content: '🎄 ';\n    margin-right: 0.5rem;\n}\n\n[data-theme=\"christmas\"] h1::after,\n[data-theme=\"christmas\"] h2::after {\n    content: ' 🎄';\n    margin-left: 0.5rem;\n}\n\n/* Ocean Theme - Wavy effect on hover */\n[data-theme=\"ocean\"] .card:hover,\n[data-theme=\"ocean\"] .bg-white:hover {\n    box-shadow:\n        0 4px 6px -1px rgba(8, 145, 178, 0.1),\n        0 2px 4px -1px rgba(8, 145, 178, 0.06);\n}\n\n/* Midnight Theme - Glow effects */\n[data-theme=\"midnight\"] button:focus,\n[data-theme=\"midnight\"] .btn:focus {\n    box-shadow:\n        0 0 0 2px rgba(139, 92, 246, 0.3),\n        0 0 12px rgba(139, 92, 246, 0.4);\n}\n\n/* Theme transition */\n* {\n    transition:\n        background-color 0.3s ease,\n        color 0.3s ease,\n        border-color 0.3s ease;\n}\n\n/* Reduce motion for users who prefer it */\n@media (prefers-reduced-motion: reduce) {\n    *,\n    *::before,\n    *::after {\n        animation-duration: 0.01ms !important;\n        animation-iteration-count: 1 !important;\n        transition-duration: 0.01ms !important;\n    }\n\n    .snowflake {\n        display: none;\n    }\n}\n\n/* Theme selector styles */\n.theme-selector {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));\n    gap: 1rem;\n    margin-top: 1rem;\n}\n\n.theme-option {\n    padding: 1rem;\n    border: 2px solid var(--theme-border);\n    border-radius: 0.5rem;\n    cursor: pointer;\n    transition: all 0.2s ease;\n    background: var(--theme-surface);\n}\n\n.theme-option:hover {\n    transform: translateY(-2px);\n    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n}\n\n.theme-option.active {\n    border-color: var(--theme-primary);\n    background: var(--theme-primary);\n    color: white;\n}\n\n.theme-preview {\n    display: flex;\n    gap: 0.5rem;\n    margin-top: 0.5rem;\n    height: 40px;\n}\n\n.theme-preview-color {\n    flex: 1;\n    border-radius: 0.25rem;\n}\n\n.theme-name {\n    font-weight: 600;\n    margin-bottom: 0.25rem;\n}\n\n.theme-description {\n    font-size: 0.875rem;\n    opacity: 0.8;\n}\n"
  },
  {
    "path": "web/static/js/darkmode.js",
    "content": "// Enhanced Dark Mode Management for SubTrackr\nclass DarkModeManager {\n    constructor() {\n        this.init();\n    }\n    \n    init() {\n        // Check system preference first, then saved preference\n        const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n        const savedPreference = localStorage.getItem('darkMode');\n        const shouldBeDark = savedPreference ? savedPreference === 'true' : systemPrefersDark;\n        \n        this.setDarkMode(shouldBeDark, false); // Don't save on init\n        this.setupSystemPreferenceListener();\n    }\n    \n    setDarkMode(enabled, save = true) {\n        document.documentElement.classList.toggle('dark', enabled);\n        if (save) {\n            localStorage.setItem('darkMode', enabled.toString());\n            this.syncWithServer(enabled);\n        }\n        \n        // Update toggle switch to match current state (if it exists on current page)\n        const toggle = document.querySelector('input[hx-post=\"/api/settings/dark-mode\"]');\n        if (toggle) {\n            toggle.checked = enabled;\n        }\n    }\n    \n    toggle() {\n        const isDark = document.documentElement.classList.contains('dark');\n        this.setDarkMode(!isDark);\n    }\n    \n    syncWithServer(enabled) {\n        fetch('/api/settings/dark-mode', {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n            body: `enabled=${enabled}`\n        }).catch(err => console.log('Failed to sync dark mode with server:', err));\n    }\n    \n    setupSystemPreferenceListener() {\n        window.matchMedia('(prefers-color-scheme: dark)')\n              .addEventListener('change', (e) => {\n                  // Only auto-switch if user hasn't set a manual preference\n                  if (!localStorage.getItem('darkMode')) {\n                      this.setDarkMode(e.matches, false);\n                  }\n              });\n    }\n}\n\n// Global dark mode manager\nlet darkModeManager;\n\n// Legacy toggle function for backward compatibility\nfunction toggleDarkMode() {\n    if (darkModeManager) {\n        darkModeManager.toggle();\n    }\n}\n\n// Initialize on DOM ready\ndocument.addEventListener('DOMContentLoaded', function() {\n    darkModeManager = new DarkModeManager();\n});"
  },
  {
    "path": "web/static/js/mobile-menu.js",
    "content": "// Mobile menu functions for responsive navigation\n// Used across all page templates to provide consistent mobile menu behavior\n\nfunction openMobileMenu() {\n    const mobileMenu = document.getElementById('mobile-menu');\n    if (mobileMenu) {\n        mobileMenu.classList.remove('hidden');\n        document.body.style.overflow = 'hidden'; // Prevent body scroll when menu is open\n    }\n}\n\nfunction closeMobileMenu() {\n    const mobileMenu = document.getElementById('mobile-menu');\n    if (mobileMenu) {\n        mobileMenu.classList.add('hidden');\n        document.body.style.overflow = ''; // Restore body scroll\n    }\n}\n\n// Close mobile menu and execute callback after menu is closed\n// Uses requestAnimationFrame to ensure DOM updates are processed\nfunction closeMobileMenuAndThen(callback) {\n    closeMobileMenu();\n    // Use double requestAnimationFrame to ensure browser has processed the DOM changes\n    // This is more reliable than setTimeout and adapts to browser rendering speed\n    requestAnimationFrame(() => {\n        requestAnimationFrame(() => {\n            if (callback) callback();\n        });\n    });\n}\n\n// Initialize mobile menu functionality when DOM is ready\ndocument.addEventListener('DOMContentLoaded', function() {\n    // Restore body scroll on page load (handles navigation before closeMobileMenu completes)\n    document.body.style.overflow = '';\n\n    // Open mobile menu when hamburger button is clicked\n    const mobileMenuButton = document.getElementById('mobile-menu-button');\n    if (mobileMenuButton) {\n        mobileMenuButton.addEventListener('click', openMobileMenu);\n    }\n\n    // Close mobile menu on escape key\n    // Close only the topmost element (modal first, then menu)\n    document.addEventListener('keydown', function(e) {\n        if (e.key === 'Escape') {\n            const modal = document.getElementById('modal');\n            const mobileMenu = document.getElementById('mobile-menu');\n            \n            // If modal is open, close it (modal is topmost)\n            if (modal && !modal.classList.contains('hidden')) {\n                modal.classList.add('hidden');\n            } \n            // Otherwise, if mobile menu is open, close it\n            else if (mobileMenu && !mobileMenu.classList.contains('hidden')) {\n                closeMobileMenu();\n            }\n        }\n    });\n});\n\n"
  },
  {
    "path": "web/static/js/sorting.js",
    "content": "// SubTrackr Sort Preference Persistence\n// Saves and restores user's sort preference using localStorage\n\nconst SORT_STORAGE_KEY = 'subtrackr-sort';\nconst VALID_SORT_FIELDS = ['name', 'cost', 'renewal_date', 'status', 'category', 'schedule', 'created_at'];\nconst VALID_SORT_ORDERS = ['asc', 'desc'];\n\n// Validate sort parameters\nfunction isValidSortPreference(sortBy, order) {\n    return VALID_SORT_FIELDS.includes(sortBy) && VALID_SORT_ORDERS.includes(order);\n}\n\n// Save sort preference to localStorage\nfunction saveSortPreference(sortBy, order) {\n    if (!isValidSortPreference(sortBy, order)) return;\n    const preference = { sortBy, order };\n    localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(preference));\n}\n\n// Get saved sort preference\nfunction getSortPreference() {\n    const stored = localStorage.getItem(SORT_STORAGE_KEY);\n    if (stored) {\n        try {\n            return JSON.parse(stored);\n        } catch (e) {\n            console.error('Failed to parse sort preference:', e);\n            return null;\n        }\n    }\n    return null;\n}\n\n// Extract sort params from URL\nfunction extractSortParams(url) {\n    try {\n        const urlObj = new URL(url, window.location.origin);\n        const sortBy = urlObj.searchParams.get('sort');\n        const order = urlObj.searchParams.get('order');\n        if (sortBy && order) {\n            return { sortBy, order };\n        }\n    } catch (e) {\n        console.error('Failed to extract sort params:', e);\n    }\n    return null;\n}\n\n// Apply saved sort preference on page load\nfunction applySavedSortPreference() {\n    const preference = getSortPreference();\n    if (!preference) return;\n\n    const subscriptionList = document.getElementById('subscription-list');\n    if (!subscriptionList) return;\n\n    // Check if we're on the subscriptions page and not already sorted\n    const currentUrl = new URL(window.location.href);\n    const currentSort = currentUrl.searchParams.get('sort');\n\n    // Validate preference before using\n    if (!isValidSortPreference(preference.sortBy, preference.order)) return;\n\n    // Only apply if no sort is currently specified in URL\n    if (!currentSort && typeof htmx !== 'undefined') {\n        // Trigger HTMX request with saved sort preference\n        const sortUrl = `/api/subscriptions?sort=${encodeURIComponent(preference.sortBy)}&order=${encodeURIComponent(preference.order)}`;\n        htmx.ajax('GET', sortUrl, {\n            target: '#subscription-list',\n            swap: 'outerHTML'\n        });\n    }\n}\n\n// Listen for HTMX requests to capture sort changes\ndocument.addEventListener('htmx:configRequest', function(event) {\n    const path = event.detail.path;\n\n    // Check if this is a sort request to subscriptions API\n    if (path && path.includes('/api/subscriptions')) {\n        const params = extractSortParams(path);\n        if (params) {\n            saveSortPreference(params.sortBy, params.order);\n        }\n    }\n});\n\n// Initialize on page load\ndocument.addEventListener('DOMContentLoaded', function() {\n    // Apply saved sort preference once HTMX is ready\n    if (typeof htmx !== 'undefined') {\n        applySavedSortPreference();\n    }\n});\n"
  },
  {
    "path": "web/static/js/theme-init.js",
    "content": "// Theme initialization - runs immediately to prevent flash\n(function() {\n    const theme = localStorage.getItem('subtrackr-theme') || 'dark-classic';\n    document.documentElement.setAttribute('data-theme', theme);\n\n    // Handle Tailwind dark mode for dark-classic theme\n    if (theme === 'dark-classic') {\n        document.documentElement.classList.add('dark');\n    }\n})();\n"
  },
  {
    "path": "web/static/js/themes.js",
    "content": "// SubTrackr Theme System\nconst themes = {\n    default: {\n        name: 'Default',\n        description: 'Clean and professional',\n        colors: {\n            primary: '#3b82f6',\n            primaryHover: '#2563eb',\n            secondary: '#64748b',\n            success: '#10b981',\n            warning: '#f59e0b',\n            danger: '#ef4444',\n            background: '#f9fafb',\n            surface: '#ffffff',\n            surfaceHover: '#f3f4f6',\n            text: '#111827',\n            textSecondary: '#6b7280',\n            border: '#e5e7eb',\n        }\n    },\n    dark: {\n        name: 'Dark',\n        description: 'Easy on the eyes',\n        colors: {\n            primary: '#3b82f6',\n            primaryHover: '#60a5fa',\n            secondary: '#64748b',\n            success: '#10b981',\n            warning: '#f59e0b',\n            danger: '#ef4444',\n            background: '#111827',\n            surface: '#1f2937',\n            surfaceHover: '#374151',\n            text: '#f9fafb',\n            textSecondary: '#9ca3af',\n            border: '#374151',\n        }\n    },\n    'dark-classic': {\n        name: 'Dark Classic',\n        description: 'Original dark mode',\n        useTailwindDark: true,  // Special flag to use Tailwind's dark mode\n        colors: {\n            primary: '#3b82f6',\n            primaryHover: '#60a5fa',\n            secondary: '#64748b',\n            success: '#10b981',\n            warning: '#f59e0b',\n            danger: '#ef4444',\n            background: '#111827',\n            surface: '#1f2937',\n            surfaceHover: '#374151',\n            text: '#f9fafb',\n            textSecondary: '#9ca3af',\n            border: '#374151',\n        }\n    },\n    christmas: {\n        name: 'Christmas',\n        description: 'Festive and jolly! 🎄',\n        colors: {\n            primary: '#c41e3a',      // Christmas red\n            primaryHover: '#a01729',\n            secondary: '#165b33',     // Forest green\n            success: '#10b981',\n            warning: '#ffd700',       // Gold\n            danger: '#ef4444',\n            background: '#fef3f3',    // Light snow white with hint of red\n            surface: '#ffffff',\n            surfaceHover: '#fef2f2',\n            text: '#1f2937',\n            textSecondary: '#6b7280',\n            border: '#fecaca',        // Light red border\n        },\n        special: {\n            accent: '#ffd700',        // Gold accents\n            snow: '#ffffff',\n            holly: '#165b33',\n            berry: '#c41e3a'\n        }\n    },\n    midnight: {\n        name: 'Midnight',\n        description: 'Deep and mysterious',\n        colors: {\n            primary: '#8b5cf6',       // Purple\n            primaryHover: '#7c3aed',\n            secondary: '#64748b',\n            success: '#10b981',\n            warning: '#f59e0b',\n            danger: '#ef4444',\n            background: '#0f172a',    // Very dark blue\n            surface: '#1e293b',\n            surfaceHover: '#334155',\n            text: '#f1f5f9',\n            textSecondary: '#94a3b8',\n            border: '#334155',\n        }\n    },\n    ocean: {\n        name: 'Ocean',\n        description: 'Cool and refreshing',\n        colors: {\n            primary: '#0891b2',       // Cyan\n            primaryHover: '#06b6d4',\n            secondary: '#64748b',\n            success: '#10b981',\n            warning: '#f59e0b',\n            danger: '#ef4444',\n            background: '#f0f9ff',    // Light cyan\n            surface: '#ffffff',\n            surfaceHover: '#e0f2fe',\n            text: '#0c4a6e',\n            textSecondary: '#475569',\n            border: '#bae6fd',\n        }\n    }\n};\n\n// Apply theme to document\nfunction applyTheme(themeName) {\n    const theme = themes[themeName] || themes['dark-classic'];\n    const root = document.documentElement;\n\n    // Set theme data attribute\n    root.setAttribute('data-theme', themeName);\n\n    // Handle Tailwind dark mode for dark-classic theme\n    if (theme.useTailwindDark) {\n        root.classList.add('dark');\n    } else {\n        root.classList.remove('dark');\n    }\n\n    // Apply CSS variables\n    Object.entries(theme.colors).forEach(([key, value]) => {\n        root.style.setProperty(`--theme-${key}`, value);\n    });\n\n    // Apply special properties for Christmas theme\n    if (themeName === 'christmas' && theme.special) {\n        Object.entries(theme.special).forEach(([key, value]) => {\n            root.style.setProperty(`--theme-special-${key}`, value);\n        });\n\n        // Enable snow animation\n        enableSnowfall();\n    } else {\n        // Disable snow animation for other themes\n        disableSnowfall();\n    }\n\n    // Save theme preference to localStorage AND server\n    localStorage.setItem('subtrackr-theme', themeName);\n    saveThemePreference(themeName);\n}\n\n// Save theme preference to server\nfunction saveThemePreference(themeName) {\n    fetch('/api/settings/theme', {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/json',\n        },\n        body: JSON.stringify({ theme: themeName })\n    })\n    .catch(err => console.error('Failed to save theme:', err));\n}\n\n// Get theme from localStorage or server\nfunction getStoredTheme() {\n    // First check localStorage for instant access\n    const localTheme = localStorage.getItem('subtrackr-theme');\n    if (localTheme) {\n        return Promise.resolve(localTheme);\n    }\n\n    // Fall back to server\n    return fetch('/api/settings/theme')\n        .then(response => response.json())\n        .then(data => {\n            const theme = data.theme || 'dark-classic';\n            localStorage.setItem('subtrackr-theme', theme);\n            return theme;\n        })\n        .catch(err => {\n            console.error('Failed to load theme:', err);\n            return 'dark-classic';\n        });\n}\n\n// Load saved theme on page load\nfunction loadSavedTheme() {\n    getStoredTheme().then(themeName => {\n        applyTheme(themeName);\n    });\n}\n\n// Snowfall animation for Christmas theme\nfunction enableSnowfall() {\n    // Remove existing snowflakes\n    disableSnowfall();\n\n    const snowContainer = document.createElement('div');\n    snowContainer.id = 'snowfall-container';\n    snowContainer.style.cssText = `\n        position: fixed;\n        top: 0;\n        left: 0;\n        width: 100%;\n        height: 100%;\n        pointer-events: none;\n        z-index: 9999;\n        overflow: hidden;\n    `;\n\n    // Create snowflakes\n    for (let i = 0; i < 50; i++) {\n        createSnowflake(snowContainer);\n    }\n\n    document.body.appendChild(snowContainer);\n}\n\nfunction createSnowflake(container) {\n    const snowflake = document.createElement('div');\n    snowflake.className = 'snowflake';\n    snowflake.innerHTML = '❄';\n\n    // Random properties\n    const size = Math.random() * 0.5 + 0.5; // 0.5 to 1em\n    const left = Math.random() * 100; // 0 to 100%\n    const animationDuration = Math.random() * 3 + 2; // 2 to 5 seconds\n    const opacity = Math.random() * 0.5 + 0.3; // 0.3 to 0.8\n    const delay = Math.random() * 5; // 0 to 5 seconds delay\n\n    snowflake.style.cssText = `\n        position: absolute;\n        top: -10%;\n        left: ${left}%;\n        font-size: ${size}em;\n        opacity: ${opacity};\n        animation: snowfall ${animationDuration}s linear ${delay}s infinite;\n        user-select: none;\n    `;\n\n    container.appendChild(snowflake);\n}\n\nfunction disableSnowfall() {\n    const snowContainer = document.getElementById('snowfall-container');\n    if (snowContainer) {\n        snowContainer.remove();\n    }\n}\n\n// Initialize on page load\ndocument.addEventListener('DOMContentLoaded', () => {\n    loadSavedTheme();\n});\n"
  },
  {
    "path": "web/static/manifest.json",
    "content": "{\n  \"name\": \"SubTrackr\",\n  \"short_name\": \"SubTrackr\",\n  \"description\": \"Track and manage your subscriptions\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#111827\",\n  \"theme_color\": \"#37889b\",\n  \"icons\": [\n    {\n      \"src\": \"/static/images/icon-192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any\"\n    },\n    {\n      \"src\": \"/static/images/icon-512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any\"\n    }\n  ]\n}\n"
  }
]