diff --git a/Dockerfile b/Dockerfile index 4508e58..162abc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ARG NODE_BUILDER_VERSION=22-slim -ARG NODE_RUNTIME_VERSION=22-alpine +ARG NODE_RUNTIME_VERSION=22-slim FROM node:${NODE_BUILDER_VERSION} AS builder WORKDIR /app @@ -12,7 +12,8 @@ COPY CHANGELOG.md ./ COPY src ./src COPY public ./public -RUN npm run build +RUN npm run build \ + && npm prune --omit=dev FROM node:${NODE_RUNTIME_VERSION} AS runtime @@ -23,6 +24,7 @@ ENV NODE_ENV=production \ PORT=8765 COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/index.html ./index.html COPY docker-entrypoint.sh ./docker-entrypoint.sh diff --git a/README.md b/README.md index 08de6d7..6e79b67 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ The dashboard pulls data from the GitHub REST and GraphQL APIs and organizes it - **Issues / Pull Requests** — cross-repo lists with the same filter sidebar, useful for triage across many projects. - **Insights** — overview of all repos with alerts ("issues need attention", "security alerts need attention", "no push for X days"), opportunities, and correlations between traffic and recent activity. Each repo gets a status (Strong / Watch / Risky). - **Alerts** — dedicated security-alert view for Dependabot and code scanning findings, so you can jump straight to the repos that need attention. -- **Daily digest** — short per-repo summary of the day's movement (stars, forks, issues), with an executive summary you can copy as Markdown. Optionally augmented by an OpenAI-generated narrative when `OPENAI_API_KEY` is configured. +- **Daily digest** — short per-repo summary of the day's movement (stars, forks, issues), with an executive summary you can copy as Markdown. Optionally augmented by an AI-generated narrative when an [AI provider](#ai-integration) is configured. - **Board** — Kanban-style view that groups issues into columns (Backlog, To-do, In progress, Ready, In review, etc.). +- **Goals** — persistent repository targets for stars, forks, closed PRs, and release downloads, with progress tracking and activity-aware AI action plans (including social post ideas). ### Per-repository view @@ -72,7 +73,7 @@ UI translations live in `src/i18n/`, with one dictionary file per language. See - **Node.js 20+** (anything that supports native `fetch` and ESM is fine). - A **GitHub OAuth App** with **Device Flow enabled** (see next section). -- (Optional) An **OpenAI API key** if you want AI-generated daily digest summaries. +- (Optional) An API key for **OpenAI, Anthropic, Google Gemini, OpenRouter or any OpenAI-compatible endpoint** if you want AI-generated digest summaries and Goals action plans (see [AI integration](#ai-integration)). ## Configure GitHub @@ -127,14 +128,41 @@ The server reads its configuration from environment variables: | `GITHUB_TOKEN` | only `token` | — | Personal access token used when `GH_AUTH_MODE=token` | | `HOST` | no | `127.0.0.1` | Interface the server binds to | | `PORT` | no | `8765` | Port the server listens on | -| `OPENAI_API_KEY` | no | — | Enables AI-generated daily digest narratives | +| `AI_PROVIDER` | no | auto-detected | AI provider: `openai`, `anthropic`, `gemini`, `openrouter` or `custom`. When unset, the first provider with a key in the environment is used | +| `OPENAI_API_KEY` | no | — | OpenAI key (also enables the provider when `AI_PROVIDER` is unset) | +| `ANTHROPIC_API_KEY` | no | — | Anthropic key | +| `GEMINI_API_KEY` | no | — | Google Gemini key (`GOOGLE_API_KEY` is accepted too) | +| `OPENROUTER_API_KEY` | no | — | OpenRouter key | +| `AI_API_KEY` | no | — | Generic key for the provider selected with `AI_PROVIDER` (required for `custom` endpoints that need one) | +| `AI_MODEL` | no | per provider | Model for the provider selected with `AI_PROVIDER`. Per-provider aliases: `OPENAI_MODEL`, `ANTHROPIC_MODEL`, `GEMINI_MODEL`, `OPENROUTER_MODEL` | +| `AI_BASE_URL` | no | per provider | Endpoint override for the provider selected with `AI_PROVIDER`, e.g. `http://localhost:11434/v1` for Ollama with `AI_PROVIDER=custom` | | `GITLAB_CLIENT_ID` | no | — | Enables GitLab OAuth when paired with `GITLAB_CLIENT_SECRET` | | `GITLAB_CLIENT_SECRET` | no | — | OAuth application secret for the selected GitLab instance | | `GITLAB_REDIRECT_URI` | no | inferred from request | Exact GitLab OAuth callback URL, ending in `/api/auth/gitlab/callback` | | `GITLAB_OAUTH_INSTANCE_URL` | no | `https://gitlab.com` | GitLab instance on which the configured OAuth app is registered | -| `OPENAI_DIGEST_MODEL` | no | `gpt-4.1-mini` | Model used for digest narratives | +| `OPENAI_DIGEST_MODEL` | no | — | Legacy alias of `OPENAI_MODEL`, still honoured | | `GITDECK_DIAGNOSTICS` | no | — | Set to `1` to log provider call durations | +### AI integration + +Digest narratives and Goals action plans are generated by a pluggable AI provider. Supported providers and their default models: + +| Provider | `AI_PROVIDER` | Key variable | Default model | Default endpoint | +| ------------ | ------------- | --------------------- | --------------------- | ---------------- | +| OpenAI | `openai` | `OPENAI_API_KEY` | `gpt-4.1-mini` | `https://api.openai.com/v1` | +| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-5` | `https://api.anthropic.com` | +| Google Gemini| `gemini` | `GEMINI_API_KEY` | `gemini-2.5-flash` | `https://generativelanguage.googleapis.com/v1beta` | +| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openai/gpt-4.1-mini` | `https://openrouter.ai/api/v1` | +| OpenAI-compatible (Ollama, Mistral, Groq, LM Studio…) | `custom` | `AI_API_KEY` (optional) | — (set `AI_MODEL`) | `http://localhost:11434/v1` | + +The configuration is layered: + +1. **Built-in defaults** (model and endpoint per provider). +2. **Environment variables** listed above. +3. **Values saved from the UI** in `~/.gitdeck/gitdeck.sqlite`, which override the environment. + +Open **Preferences → All preferences** (or go to `/preferences`) to pick the provider, store an API key, model or base URL, test the connection, and see for every field whether the value in effect comes from the database, the environment or a default. *Reset to environment* removes every stored override. Keys saved from the UI never leave the server: the API only returns a masked version. + ### Authentication modes The dashboard can obtain a GitHub token in three different ways. Pick the one that fits your setup: @@ -145,7 +173,13 @@ The dashboard can obtain a GitHub token in three different ways. Pick the one th In `gh-cli` and `token` modes the device-flow sign-in screen is hidden; the server treats the configured source as authoritative. -Tokens and snapshots are persisted under `~/.gitdeck/`. If you previously ran an older build that stored data in `~/.gh-issues-dashboard/`, the server migrates it automatically on first start. +Tokens and snapshots are persisted under `~/.gitdeck/`. Goals and server-side preferences are stored in `~/.gitdeck/gitdeck.sqlite`. If you previously ran an older build that stored data in `~/.gh-issues-dashboard/`, the server migrates it automatically on first start. + +### Extending persisted preferences and Goals + +Use `setPreference(scope, key, value)` and `getPreference(scope, key, fallback)` from `src/server/preferenceStore.ts` to persist any JSON-serialisable preference without creating a new schema. Low-level parameterised SQLite helpers are in `src/server/sqlite.ts`. + +To add a Goal metric, add one metadata entry to `GOAL_METRIC_DEFINITIONS` in `src/types/goals.ts` and its resolver to `METRIC_RESOLVERS` in `src/server/goals.ts`. The type, creation UI, persistence, progress UI, and AI context update without further wiring. ### GitLab accounts @@ -218,10 +252,14 @@ With Docker Compose (recommended): ```bash cat > .env <<'EOF' GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx -# Optional — enables AI-generated daily digest narratives +# Optional — enables AI-generated digest narratives and Goals plans (any one provider) OPENAI_API_KEY=sk-... +# ANTHROPIC_API_KEY=... +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... # Optional overrides -# OPENAI_DIGEST_MODEL=gpt-4.1-mini +# AI_PROVIDER=openrouter +# AI_MODEL=anthropic/claude-sonnet-5 # GITHUB_OAUTH_SCOPES=repo read:org project read:user user:email EOF docker compose up -d --build @@ -240,7 +278,7 @@ docker run -d --name gitdeck \ gitdeck ``` -The container forwards `GITHUB_CLIENT_ID`, `GITHUB_OAUTH_SCOPES`, `OPENAI_API_KEY` and `OPENAI_DIGEST_MODEL` from the host environment (or `.env` with Compose) — see [Configuration](#configuration) for the full list. It sets `HOST=0.0.0.0` so the server is reachable from outside. To wipe the stored token (full logout) remove the volume: `docker volume rm gitdeck-data`. +The container forwards `GITHUB_CLIENT_ID`, `GITHUB_OAUTH_SCOPES` and the AI variables (`AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`, `AI_BASE_URL`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY`) from the host environment (or `.env` with Compose) — see [Configuration](#configuration) for the full list. It sets `HOST=0.0.0.0` so the server is reachable from outside. To wipe the stored token (full logout) remove the volume: `docker volume rm gitdeck-data`. ## Test & type-check diff --git a/docker-compose.yml b/docker-compose.yml index 4185829..a6377b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,15 @@ services: environment: GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:?set GITHUB_CLIENT_ID in env or .env} GITHUB_OAUTH_SCOPES: ${GITHUB_OAUTH_SCOPES:-} + AI_PROVIDER: ${AI_PROVIDER:-} + AI_API_KEY: ${AI_API_KEY:-} + AI_MODEL: ${AI_MODEL:-} + AI_BASE_URL: ${AI_BASE_URL:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} - OPENAI_DIGEST_MODEL: ${OPENAI_DIGEST_MODEL:-} + OPENAI_MODEL: ${OPENAI_MODEL:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} volumes: - gitdeck-data:/home/node/.gitdeck diff --git a/package-lock.json b/package-lock.json index f785052..949b42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "gitdeck", "version": "1.0.8", "dependencies": { + "better-sqlite3": "^11.10.0", "find-my-way": "^9.9.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -16,6 +17,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -1116,6 +1118,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1422,6 +1434,37 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1432,6 +1475,50 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -1522,6 +1609,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1788,6 +1881,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1801,7 +1918,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1840,6 +1956,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -1944,6 +2069,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2009,6 +2143,12 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/find-my-way": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", @@ -2023,6 +2163,12 @@ "node": ">=20" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2061,6 +2207,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -2176,6 +2328,38 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -3460,16 +3644,33 @@ ], "license": "MIT" }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3495,6 +3696,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -3502,6 +3709,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-package-data": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz", @@ -3528,6 +3747,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3622,6 +3850,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -3632,6 +3887,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3642,6 +3907,21 @@ "node": ">=6" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -3728,6 +4008,20 @@ "react-dom": ">=18" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -3884,6 +4178,26 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -3929,7 +4243,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3964,6 +4277,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4044,6 +4402,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4086,6 +4453,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -4127,6 +4503,34 @@ "dev": true, "license": "MIT" }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4758,6 +5162,18 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4890,6 +5306,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -5197,6 +5619,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 585ebeb..9a7ebd5 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "api": "tsx watch src/server.ts", "dev": "concurrently \"npm:api\" \"vite --host 127.0.0.1\"", - "build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node22 --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outfile=dist/server.js && vite build", + "build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node22 --external:better-sqlite3 --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outfile=dist/server.js && vite build", "preview": "vite preview --host 127.0.0.1", "serve": "node dist/server.js", "start": "node dist/server.js", @@ -16,6 +16,7 @@ "version": "node scripts/sync-version.js && conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md src/version.ts" }, "dependencies": { + "better-sqlite3": "^11.10.0", "find-my-way": "^9.9.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -24,6 +25,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/App.tsx b/src/App.tsx index 3aab5b7..6a4083f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { fetchAuthStatus, fetchCIHealth, fetchDailyDigests, + fetchGoals, fetchNotifications, fetchRepoInsights, logoutAuth, @@ -20,9 +21,10 @@ import { WelcomeModal } from "./components/modals/WelcomeModal"; import { CommandPalette } from "./components/modals/CommandPalette"; import { Footer } from "./components/Footer"; import { TopBar } from "./components/TopBar"; +import { PreferencesView } from "./components/views/PreferencesView"; import { SidebarControls, type InboxSidebarState } from "./components/SidebarControls"; import { Pagination } from "./components/common/Pagination"; -import { BoardIcon, BookIcon, ExportIcon, InboxIcon, IssueIcon, LoadingIcon, PulseIcon } from "./components/common/Icons"; +import { AlertIcon, BoardIcon, BookIcon, CIIcon, DigestIcon, ExportIcon, GoalIcon, InboxIcon, InsightsIcon, IssueIcon, LoadingIcon, PullRequestIcon } from "./components/common/Icons"; import { IssueList } from "./components/views/IssueList"; import { PullRequestList } from "./components/views/PullRequestList"; import { DailyDigestView } from "./components/views/DailyDigestView"; @@ -31,6 +33,8 @@ import { InsightsView } from "./components/views/InsightsView"; import { RepoGrid } from "./components/views/RepoGrid"; import { KanbanView } from "./components/views/KanbanView"; import { CIHealthView } from "./components/views/CIHealthView"; +import { GoalsView } from "./components/views/GoalsView"; +import type { RepositoryGoal } from "./types/goals"; import type { CIHealthData, DailyDigestEntry, @@ -67,7 +71,7 @@ import { useI18n } from "./i18n/I18nProvider"; import { useAccounts, useCapability } from "./contexts/AccountContext"; import { useDashboardData } from "./hooks/useDashboardData"; -type Tab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests"; +type Tab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "goals"; type Theme = "dark" | "light" | "auto"; type TextSize = "small" | "normal" | "large"; @@ -81,8 +85,11 @@ const TAB_ROUTES: Record = { alerts: "/alerts", ci: "/ci", digests: "/daily", + goals: "/goals", }; +const PREFERENCES_ROUTE = "/preferences"; + const ROUTE_TABS = new Map(Object.entries(TAB_ROUTES).map(([tab, route]) => [route, tab as Tab])); const DETAIL_TABS = new Set(["overview", "actions", "commits", "pull-requests", "issues", "milestones", "releases", "branches", "forks", "traffic", "mentions", "discussions", "dependents"]); const METRIC_KINDS = new Set(["stars", "forks"]); @@ -168,6 +175,9 @@ export function App() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const tab = tabFromPath(location.pathname); + const isPreferencesPage = location.pathname === PREFERENCES_ROUTE; + // `view` is null on the preferences page so no dashboard tab content renders there. + const view: Tab | null = isPreferencesPage ? null : tab; const routeRepoName = searchParams.get("repo") || ""; const repoDetailTab = detailTabFromParams(searchParams); const routeMetricKind = metricKindFromParams(searchParams); @@ -186,6 +196,8 @@ export function App() { const [dailyDigests, setDailyDigests] = useState([]); const [digestPeriod, setDigestPeriod] = useState(() => (localStorage.getItem("gh-dash.digestPeriod") as DigestPeriod) || "day"); const [ciHealth, setCiHealth] = useState([]); + const [goals, setGoals] = useState([]); + const [goalsLoaded, setGoalsLoaded] = useState(false); const [insightsLoaded, setInsightsLoaded] = useState(false); const [ciLoaded, setCiLoaded] = useState(false); const [digestsLoaded, setDigestsLoaded] = useState(false); @@ -228,6 +240,8 @@ export function App() { setRepoInsights([]); setDailyDigests([]); setCiHealth([]); + setGoals([]); + setGoalsLoaded(false); setInsightsLoaded(false); setCiLoaded(false); setDigestsLoaded(false); @@ -279,6 +293,24 @@ export function App() { loadAll(); }, [authState, paletteOpen, loadAll]); + const refreshGoals = useCallback(async () => { + const data = await fetchGoals(); + setGoals(data.goals); + setGoalsLoaded(true); + }, []); + + useEffect(() => { + if (authState !== "authenticated" || tab !== "goals") return; + const controller = new AbortController(); + fetchGoals(controller.signal).then((data) => { + if (!controller.signal.aborted) { + setGoals(data.goals); + setGoalsLoaded(true); + } + }).catch(() => { if (!controller.signal.aborted) setGoalsLoaded(true); }); + return () => controller.abort(); + }, [authState, activeAccountId, tab]); + useEffect(() => { if (authState !== "authenticated") return; if (tab !== "ci") return; @@ -340,6 +372,8 @@ export function App() { setRepoInsights([]); setDailyDigests([]); setCiHealth([]); + setGoals([]); + setGoalsLoaded(false); setInsightsLoaded(false); setCiLoaded(false); setDigestsLoaded(false); @@ -372,8 +406,10 @@ export function App() { document.body.classList.toggle("tab-alerts", tab === "alerts"); document.body.classList.toggle("tab-ci", tab === "ci"); document.body.classList.toggle("tab-digests", tab === "digests"); + document.body.classList.toggle("tab-goals", tab === "goals"); + document.body.classList.toggle("route-preferences", isPreferencesPage); document.body.classList.toggle("filters-open", filtersOpen); - }, [tab, filtersOpen]); + }, [tab, filtersOpen, isPreferencesPage]); useEffect(() => { if (location.pathname === "/" || location.pathname === "/index.html") { @@ -623,7 +659,7 @@ export function App() { const search = tab === "inbox" ? inboxSearch - : tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" + : tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals" ? repoFilters.search : tab === "prs" ? prFilters.search @@ -642,7 +678,7 @@ export function App() { if (tab === "inbox") { setInboxSearch(value); setInboxPage(1); - } else if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests") { + } else if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals") { setRepoFilters({ ...repoFilters, search: value }); setRepoPage(1); } else if (tab === "prs") { @@ -655,7 +691,7 @@ export function App() { } function resetFilters() { - if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests") setRepoFilters(defaultRepoFilters()); + if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals") setRepoFilters(defaultRepoFilters()); else if (tab === "prs") setPrFilters(defaultPrFilters()); else setIssueFilters(defaultIssueFilters()); clearFiltersCache(); @@ -694,11 +730,12 @@ export function App() { { key: "inbox" as const, label: t("tabs.inbox"), count: issues.length + pullRequests.length, ready: inboxLoaded, icon: }, { key: "repos" as const, label: t("tabs.repositories"), count: repos.length, ready: reposLoaded, icon: }, { key: "issues" as const, label: t("tabs.issues"), count: issues.length, ready: issuesLoaded, icon: }, - { key: "prs" as const, label: t("tabs.pullRequests"), count: pullRequests.length, ready: prsLoaded, icon: }, - { key: "insights" as const, label: t("tabs.insights"), count: filteredInsights.length, ready: insightsLoaded, icon: }, - { key: "alerts" as const, label: t("tabs.alerts"), count: totalSecurityAlerts, ready: insightsLoaded, icon: }, - { key: "ci" as const, label: t("tabs.ci"), count: ciHealth.length, ready: ciLoaded, icon: }, - { key: "digests" as const, label: t("tabs.digest"), count: dailyDigests.length, ready: digestsLoaded, icon: }, + { key: "prs" as const, label: t("tabs.pullRequests"), count: pullRequests.length, ready: prsLoaded, icon: }, + { key: "insights" as const, label: t("tabs.insights"), count: filteredInsights.length, ready: insightsLoaded, icon: }, + { key: "alerts" as const, label: t("tabs.alerts"), count: totalSecurityAlerts, ready: insightsLoaded, icon: }, + { key: "ci" as const, label: t("tabs.ci"), count: ciHealth.length, ready: ciLoaded, icon: }, + { key: "digests" as const, label: t("tabs.digest"), count: dailyDigests.length, ready: digestsLoaded, icon: }, + { key: "goals" as const, label: t("tabs.goals"), count: goals.length, ready: goalsLoaded, icon: }, ...(projectsEnabled ? [{ key: "kanban" as const, label: t("tabs.board"), count: boardCount, ready: boardLoaded, icon: }] : []), @@ -722,6 +759,8 @@ export function App() { onRefresh={() => loadData(dataRequirementsForTab(tab, Boolean(routeRepoName), repoDetailTab), true)} onOpenFilters={() => setFiltersOpen(true)} onOpenPalette={() => setPaletteOpen(true)} + onOpenPreferencesPage={() => navigate(PREFERENCES_ROUTE)} + preferencesPageActive={isPreferencesPage} onLogout={() => void handleLogout()} canLogout={authMode === "device"} /> @@ -749,6 +788,18 @@ export function App() { />
{error ?
{error}
: null} + {isPreferencesPage ? ( + navigate(TAB_ROUTES[tab])} + /> + ) : null} + {view ? (
{tabs.map((item) => ( @@ -762,8 +813,9 @@ export function App() { ))}
+ ) : null} - {tab === "inbox" ? ( + {view === "inbox" ? ( ) : null} - {tab === "issues" ? ( + {view === "issues" ? (
{t("stats.openIssues")}
{countText(filteredIssues.length, issuesLoaded)}
{t("stats.matchingFilters")}
@@ -807,7 +859,7 @@ export function App() {
) : null} - {tab === "prs" ? ( + {view === "prs" ? (
{t("stats.openPrs")}
{countText(filteredPullRequests.length, prsLoaded)}
{t("stats.matchingFilters")}
@@ -851,7 +903,7 @@ export function App() {
) : null} - {tab === "repos" ? ( + {view === "repos" ? (
{t("stats.repositories")}
{countText(filteredRepos.length, reposLoaded)}
{t("stats.matchingFilters")}
@@ -892,7 +944,7 @@ export function App() {
) : null} - {tab === "insights" ? ( + {view === "insights" ? (
{t("stats.averageHealth")}
{countText(averageHealth, insightsLoaded)}
{t("stats.acrossTrackedRepos")}
@@ -904,7 +956,7 @@ export function App() {
) : null} - {tab === "alerts" ? ( + {view === "alerts" ? (
{t("alerts.totalAlerts")}
{countText(totalSecurityAlerts, insightsLoaded)}
{t("alerts.affectedRepos", { count: countText(securityRepoCount, insightsLoaded) })}
@@ -922,7 +974,7 @@ export function App() {
) : null} - {tab === "ci" ? ( + {view === "ci" ? ( (() => { const totalRuns = ciHealth.reduce((sum, entry) => sum + entry.totalRuns, 0); const totalFailures = ciHealth.reduce((sum, entry) => sum + entry.failureCount, 0); @@ -944,7 +996,7 @@ export function App() { })() ) : null} - {tab === "digests" ? ( + {view === "digests" ? (
{digestPeriod === "day" ? t("stats.digestDays") : digestPeriod === "week" ? t("stats.digestWeeks") : t("stats.digestMonths")}
{countText(dailyDigests.length, digestsLoaded)}
{digestPeriod === "day" ? t("stats.daysWithSavedSnapshots") : t("stats.periodsAggregated")}
@@ -957,7 +1009,9 @@ export function App() {
) : null} - {tab === "kanban" && projectsEnabled ? { setBoardCount(count); setBoardLoaded(true); }} /> : null} + {view === "goals" ? : null} + + {view === "kanban" && projectsEnabled ? { setBoardCount(count); setBoardLoaded(true); }} /> : null}