From 502029e4d79d83dca22c8c19a59f97fe1d7744d6 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 3 Sep 2026 17:41:33 +0200 Subject: [PATCH 01/54] feat: add repository goals and social plans --- Dockerfile | 6 +- README.md | 54 ++- docker-compose.yml | 9 +- package-lock.json | 434 +++++++++++++++++- package.json | 4 +- src/App.tsx | 94 +++- src/api/github.ts | 53 +++ src/components/SidebarControls.tsx | 2 +- src/components/TopBar.tsx | 15 +- src/components/common/Icons.tsx | 24 + src/components/common/RepositoryPicker.tsx | 93 ++++ src/components/modals/GoalProposalsModal.tsx | 179 ++++++++ .../modals/RepositoryDetailsModal.tsx | 2 +- .../preferences/AiIntegrationSettings.tsx | 268 +++++++++++ src/components/views/DailyDigestView.tsx | 2 +- src/components/views/GoalsLoadingState.tsx | 51 ++ src/components/views/GoalsView.tsx | 257 +++++++++++ src/components/views/PreferencesView.tsx | 134 ++++++ src/i18n/en.ts | 95 ++++ src/i18n/it.ts | 95 ++++ src/server/ai/client.ts | 212 +++++++++ src/server/ai/providers.ts | 83 ++++ src/server/ai/settings.ts | 180 ++++++++ src/server/aiDigest.ts | 83 ++++ src/server/digests.ts | 6 +- src/server/goalStore.ts | 114 +++++ src/server/goals.ts | 257 +++++++++++ src/server/openaiDigest.ts | 118 ----- src/server/preferenceStore.ts | 46 ++ src/server/routes/ai.ts | 58 +++ src/server/routes/goals.ts | 100 ++++ src/server/routes/index.ts | 4 + src/server/spa.ts | 2 + src/server/sqlite.ts | 38 ++ src/styles.css | 1 + src/styles/goals.css | 198 ++++++++ src/styles/preferences.css | 162 +++++++ src/types/ai.ts | 49 ++ src/types/github.ts | 2 + src/types/goals.ts | 61 +++ src/utils/dataRequirements.ts | 3 +- src/utils/digests.ts | 1 + src/utils/goals.ts | 49 ++ src/utils/socialProposals.ts | 80 ++++ tests/server/aiClient.test.ts | 112 +++++ tests/server/aiSettings.test.ts | 112 +++++ tests/utils/goals.test.ts | 54 +++ tests/utils/socialProposals.test.ts | 35 ++ 48 files changed, 3930 insertions(+), 161 deletions(-) create mode 100644 src/components/common/RepositoryPicker.tsx create mode 100644 src/components/modals/GoalProposalsModal.tsx create mode 100644 src/components/preferences/AiIntegrationSettings.tsx create mode 100644 src/components/views/GoalsLoadingState.tsx create mode 100644 src/components/views/GoalsView.tsx create mode 100644 src/components/views/PreferencesView.tsx create mode 100644 src/server/ai/client.ts create mode 100644 src/server/ai/providers.ts create mode 100644 src/server/ai/settings.ts create mode 100644 src/server/aiDigest.ts create mode 100644 src/server/goalStore.ts create mode 100644 src/server/goals.ts delete mode 100644 src/server/openaiDigest.ts create mode 100644 src/server/preferenceStore.ts create mode 100644 src/server/routes/ai.ts create mode 100644 src/server/routes/goals.ts create mode 100644 src/server/sqlite.ts create mode 100644 src/styles/goals.css create mode 100644 src/types/ai.ts create mode 100644 src/types/goals.ts create mode 100644 src/utils/goals.ts create mode 100644 src/utils/socialProposals.ts create mode 100644 tests/server/aiClient.test.ts create mode 100644 tests/server/aiSettings.test.ts create mode 100644 tests/utils/goals.test.ts create mode 100644 tests/utils/socialProposals.test.ts 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}
{ + return readJson("/api/goals", withSignal(signal)); +} + +export function createGoal(payload: { + repository: string; + metric: GoalMetric; + targetValue: number; + currentValue?: number; + deadline: string; +}): Promise<{ ok: true; goal: RepositoryGoal }> { + return readJson("/api/goals", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export function deleteGoal(id: string): Promise<{ ok: true }> { + return readJson(`/api/goals/${encodeURIComponent(id)}`, { method: "DELETE" }); +} + +export function generateGoalAdvice(id: string): Promise<{ ok: true; suggestions: GoalSuggestion[]; generatedAt: string; aiEnabled: boolean }> { + return readJson(`/api/goals/${encodeURIComponent(id)}/advice`, { method: "POST" }); +} + +export function fetchGoalProposals(goalId: string, suggestionIndex: number, refresh = false): Promise { + const query = refresh ? "?refresh=1" : ""; + return readJson(`/api/goals/${encodeURIComponent(goalId)}/suggestions/${suggestionIndex}/proposals${query}`, { method: "POST" }); +} + +export function fetchAiSettings(): Promise<{ ok: true; settings: AiSettingsSummary }> { + return readJson("/api/ai/settings"); +} + +export function updateAiSettings(payload: AiSettingsUpdate): Promise<{ ok: true; settings: AiSettingsSummary }> { + return readJson("/api/ai/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export function resetAiSettings(): Promise<{ ok: true; settings: AiSettingsSummary }> { + return readJson("/api/ai/settings", { method: "DELETE" }); +} + +export function testAiSettings(): Promise { + return readJson("/api/ai/settings/test", { method: "POST" }); +} + export function fetchRepos(fresh = false, signal?: AbortSignal): Promise { return readJson(`/api/repos${fresh ? "?fresh=1" : ""}`, withSignal(signal), "/api/repos"); } diff --git a/src/components/SidebarControls.tsx b/src/components/SidebarControls.tsx index 223bff5..8c4443c 100644 --- a/src/components/SidebarControls.tsx +++ b/src/components/SidebarControls.tsx @@ -6,7 +6,7 @@ import { formatNumber } from "../utils/format"; import { ChevronIcon, CloseIcon, SearchIcon } from "./common/Icons"; import { useI18n } from "../i18n/I18nProvider"; -type Tab = "inbox" | "issues" | "repos" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "prs"; +type Tab = "inbox" | "issues" | "repos" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "prs" | "goals"; export interface InboxSidebarState { mailbox: InboxMailbox; diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 8ad981d..8b30aa4 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -23,6 +23,8 @@ interface TopBarProps { onRefresh: () => void; onOpenFilters: () => void; onOpenPalette: () => void; + onOpenPreferencesPage: () => void; + preferencesPageActive?: boolean; onLogout: () => void; canLogout?: boolean; } @@ -43,6 +45,8 @@ export function TopBar({ onRefresh, onOpenFilters, onOpenPalette, + onOpenPreferencesPage, + preferencesPageActive = false, onLogout, canLogout = true, }: TopBarProps) { @@ -100,7 +104,7 @@ export function TopBar({
+ ) : null} diff --git a/src/components/common/Icons.tsx b/src/components/common/Icons.tsx index 32fc435..1ab825b 100644 --- a/src/components/common/Icons.tsx +++ b/src/components/common/Icons.tsx @@ -46,6 +46,30 @@ export function PulseIcon() { return ; } +export function PullRequestIcon() { + return ; +} + +export function InsightsIcon() { + return ; +} + +export function AlertIcon() { + return ; +} + +export function CIIcon() { + return ; +} + +export function DigestIcon() { + return ; +} + +export function GoalIcon() { + return ; +} + export function InboxIcon() { return ; } diff --git a/src/components/common/RepositoryPicker.tsx b/src/components/common/RepositoryPicker.tsx new file mode 100644 index 0000000..fbcc32b --- /dev/null +++ b/src/components/common/RepositoryPicker.tsx @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { GhRepo } from "../../types/github"; +import { formatNumber } from "../../utils/format"; +import { BookIcon } from "./Icons"; + +interface RepositoryPickerProps { + repos: GhRepo[]; + value: string; + placeholder: string; + onChange: (repository: string) => void; +} + +export function RepositoryPicker({ repos, value, placeholder, onChange }: RepositoryPickerProps) { + const rootRef = useRef(null); + const [query, setQuery] = useState(value); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => setQuery(value), [value]); + useEffect(() => { + const close = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, []); + + const matches = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + const sorted = [...repos].sort((a, b) => b.stargazerCount - a.stargazerCount); + if (!needle || query === value) return sorted.slice(0, 12); + return sorted.filter((repo) => `${repo.nameWithOwner} ${repo.description ?? ""} ${repo.primaryLanguage?.name ?? ""}`.toLocaleLowerCase().includes(needle)).slice(0, 12); + }, [query, repos, value]); + + function select(repo: GhRepo) { + onChange(repo.nameWithOwner); + setQuery(repo.nameWithOwner); + setOpen(false); + } + + return ( +
+
+ + setOpen(true)} + onChange={(event) => { + setQuery(event.target.value); + if (event.target.value !== value) onChange(""); + setActiveIndex(0); + setOpen(true); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { event.preventDefault(); setOpen(true); setActiveIndex((index) => Math.min(index + 1, matches.length - 1)); } + if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((index) => Math.max(index - 1, 0)); } + if (event.key === "Enter" && open && matches[activeIndex]) { event.preventDefault(); select(matches[activeIndex]); } + if (event.key === "Escape") setOpen(false); + }} + /> + +
+ {open ? ( +
+
{matches.length ? `${matches.length} repositories` : "No repositories found"}
+ {matches.map((repo, index) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/modals/GoalProposalsModal.tsx b/src/components/modals/GoalProposalsModal.tsx new file mode 100644 index 0000000..3e37a34 --- /dev/null +++ b/src/components/modals/GoalProposalsModal.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { fetchGoalProposals } from "../../api/github"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { GoalProposal, GoalSuggestion, RepositoryGoal } from "../../types/goals"; +import { formatRelativeTime } from "../../utils/format"; +import { formatXThreadForCopy } from "../../utils/goals"; +import { socialCharacterCount } from "../../utils/socialProposals"; +import { CloseIcon, GoalIcon } from "../common/Icons"; +import { Markdown } from "../common/Markdown"; + +interface GoalProposalsModalProps { + goal: RepositoryGoal; + suggestion: GoalSuggestion; + suggestionIndex: number; + onClose: () => void; + onOpenPreferences: () => void; + /** Called with the fresh proposals so the parent can keep its goal list in sync. */ + onProposals?: (proposals: GoalProposal[], generatedAt: string) => void; +} + +type LoadState = + | { kind: "loading" } + | { kind: "ready"; proposals: GoalProposal[]; generatedAt: string } + | { kind: "no-ai" } + | { kind: "error"; message: string }; + +function CopyButton({ text, label }: { text: string; label?: string }) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1600); + return () => window.clearTimeout(timer); + }, [copied]); + return ( + + ); +} + +/** + * Shows AI-drafted deliverables for one recommended action of a goal. Drafts + * are cached server-side per suggestion; "Regenerate" asks for new ones. + */ +export function GoalProposalsModal({ goal, suggestion, suggestionIndex, onClose, onOpenPreferences, onProposals }: GoalProposalsModalProps) { + const { t, language } = useI18n(); + const [state, setState] = useState({ kind: "loading" }); + const [refreshing, setRefreshing] = useState(false); + + async function load(refresh: boolean) { + if (refresh) setRefreshing(true); + else setState({ kind: "loading" }); + try { + const result = await fetchGoalProposals(goal.id, suggestionIndex, refresh); + setState({ kind: "ready", proposals: result.proposals, generatedAt: result.generatedAt }); + onProposals?.(result.proposals, result.generatedAt); + } catch (error) { + const message = (error as Error).message; + setState(/not configured/i.test(message) ? { kind: "no-ai" } : { kind: "error", message }); + } finally { + setRefreshing(false); + } + } + + useEffect(() => { + void load(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [goal.id, suggestionIndex]); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return createPortal( +
+
+
+
+
+ +
+
{t("goals.proposalsKind")} · {suggestion.category}
+

{suggestion.title}

+
+
+ +
+ +
+

{t("goals.proposalsIntro", { repo: goal.repository })}

+
{suggestion.action}
+ + {state.kind === "loading" ? ( +
+
+ ) : null} + + {state.kind === "no-ai" ? ( +
+

{t("goals.proposalsNoAi")}

+ +
+ ) : null} + + {state.kind === "error" ?
{state.message}
: null} + + {state.kind === "ready" ? ( + state.proposals.length ? ( +
+ {state.proposals.map((proposal, index) => ( +
+
+ {t(`goals.proposalFormat.${proposal.format}`)} +
+ {proposal.title} + {proposal.summary ? {proposal.summary} : null} +
+ +
+ {proposal.format === "x-thread" && proposal.threadPosts?.length ? ( +
+ {proposal.threadPosts.map((post, postIndex) => ( +
+ +
+
+ {goal.repository} + {postIndex + 1}/{proposal.threadPosts!.length} + +
+
{post}
+ 280 ? "over-limit" : ""}>{socialCharacterCount(post)}/280 +
+
+ ))} +
+ ) : {proposal.content}} +
+ ))} +
+ ) :

{t("goals.proposalsEmpty")}

+ ) : null} +
+ +
+ + {state.kind === "ready" && state.generatedAt ? t("goals.proposalsGeneratedAt", { time: formatRelativeTime(state.generatedAt, Date.now(), language) }) : ""} + +
+ {state.kind === "ready" || state.kind === "error" ? ( + + ) : null} + +
+
+
, + document.body, + ); +} diff --git a/src/components/modals/RepositoryDetailsModal.tsx b/src/components/modals/RepositoryDetailsModal.tsx index 705f145..ee69add 100644 --- a/src/components/modals/RepositoryDetailsModal.tsx +++ b/src/components/modals/RepositoryDetailsModal.tsx @@ -726,7 +726,7 @@ export function RepositoryDetailsModal({ repo, issues, pullRequests, issuesLoade
{repoDigest.ai.headline} - {repoDigest.ai.model} + {repoDigest.ai.provider ? `${repoDigest.ai.provider} · ${repoDigest.ai.model}` : repoDigest.ai.model}
{repoDigest.ai.briefing.map((item) =>

{item}

)} diff --git a/src/components/preferences/AiIntegrationSettings.tsx b/src/components/preferences/AiIntegrationSettings.tsx new file mode 100644 index 0000000..ac83bf2 --- /dev/null +++ b/src/components/preferences/AiIntegrationSettings.tsx @@ -0,0 +1,268 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { fetchAiSettings, resetAiSettings, testAiSettings, updateAiSettings } from "../../api/github"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { AiProviderId, AiProviderInfo, AiSettingSource, AiSettingsSummary } from "../../types/ai"; +import { ConfirmDialog } from "../common/ConfirmDialog"; + +type Notice = { kind: "ok" | "error"; text: string } | null; + +const PROVIDER_GLYPHS: Record = { + openai: "O", + anthropic: "A", + gemini: "G", + openrouter: "R", + custom: "⌁", +}; + +function SourceBadge({ source }: { source: AiSettingSource }) { + const { t } = useI18n(); + return {t(`preferences.ai.source.${source}`)}; +} + +function ProviderCard({ info, selected, active, onSelect }: { info: AiProviderInfo; selected: boolean; active: boolean; onSelect: () => void }) { + const { t } = useI18n(); + const keyTag = info.hasStoredKey ? "stored" : info.hasEnvKey ? "env" : info.requiresApiKey ? "none" : null; + return ( + + ); +} + +/** + * Editor for the server-side AI provider. Values come from the environment by + * default; anything saved here is stored in SQLite and takes precedence, and + * each field shows which layer is currently in effect. + */ +export function AiIntegrationSettings() { + const { t } = useI18n(); + const [settings, setSettings] = useState(null); + const [loadError, setLoadError] = useState(""); + const [provider, setProvider] = useState("openai"); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [busy, setBusy] = useState<"save" | "test" | "reset" | "removeKey" | null>(null); + const [notice, setNotice] = useState(null); + const [confirmReset, setConfirmReset] = useState(false); + + function applySummary(next: AiSettingsSummary) { + setSettings(next); + setProvider(next.provider.value); + setApiKey(""); + setModel(next.model.source === "database" ? next.model.value ?? "" : ""); + setBaseUrl(next.baseUrl.source === "database" ? next.baseUrl.value : ""); + } + + useEffect(() => { + let cancelled = false; + fetchAiSettings() + .then((result) => { if (!cancelled) applySummary(result.settings); }) + .catch((error: Error) => { if (!cancelled) setLoadError(error.message); }); + return () => { cancelled = true; }; + }, []); + + const info = settings?.providers.find((entry) => entry.id === provider) ?? null; + const isActiveProvider = settings?.provider.value === provider; + + async function run(kind: NonNullable, action: () => Promise) { + setBusy(kind); + setNotice(null); + try { + const result = await action(); + if (typeof result === "string") setNotice({ kind: "ok", text: result }); + else { + applySummary(result); + setNotice({ kind: "ok", text: t("preferences.ai.saved") }); + } + } catch (error) { + setNotice({ kind: "error", text: (error as Error).message }); + } finally { + setBusy(null); + } + } + + function selectProvider(next: AiProviderId) { + if (!settings) return; + setProvider(next); + setNotice(null); + setApiKey(""); + const entry = settings.providers.find((item) => item.id === next); + setModel(entry?.storedModel ?? ""); + setBaseUrl(entry?.storedBaseUrl ?? ""); + } + + function save(event: FormEvent) { + event.preventDefault(); + if (!info) return; + if (!info.defaultModel && !model.trim() && !(isActiveProvider && settings?.model.value)) { + setNotice({ kind: "error", text: t("preferences.ai.modelRequired") }); + return; + } + void run("save", async () => { + const payload = { provider, model, baseUrl, ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}) }; + return (await updateAiSettings(payload)).settings; + }); + } + + if (loadError) return
{t("preferences.ai.loadError")}: {loadError}
; + if (!settings || !info) return
{t("common.loading")}
; + + const activeInfo = settings.providers.find((entry) => entry.id === settings.provider.value); + // Placeholders show what applies when the field is left empty. + const keyPlaceholder = isActiveProvider && settings.apiKey.configured + ? `${settings.apiKey.masked} · ${t("preferences.ai.apiKeyKeep")}` + : info.hasEnvKey ? t("preferences.ai.envHint", { name: info.envKeyName }) : t("preferences.ai.apiKeyMissing"); + const modelPlaceholder = isActiveProvider && settings.model.source !== "database" && settings.model.value + ? settings.model.value + : info.defaultModel ?? t("preferences.ai.modelRequired"); + const baseUrlPlaceholder = isActiveProvider && settings.baseUrl.source !== "database" ? settings.baseUrl.value : info.defaultBaseUrl; + const keySource: AiSettingSource = isActiveProvider ? settings.apiKey.source : info.hasStoredKey ? "database" : info.hasEnvKey ? "env" : "none"; + const modelSource: AiSettingSource = isActiveProvider ? settings.model.source : info.storedModel ? "database" : info.defaultModel ? "default" : "none"; + const baseUrlSource: AiSettingSource = isActiveProvider ? settings.baseUrl.source : info.storedBaseUrl ? "database" : "default"; + const hasOverrides = settings.provider.source === "database" || settings.providers.some((entry) => entry.hasStoredKey || entry.storedModel || entry.storedBaseUrl); + const showBaseUrl = info.supportsBaseUrl || baseUrlSource !== "default" || Boolean(baseUrl); + + return ( +
+
+ {settings.enabled ? t("preferences.ai.statusReady") : t("preferences.ai.statusIncomplete")} +
+ {activeInfo?.label} + {settings.model.value ? {settings.model.value} : null} +
+
+ {t("preferences.ai.provider")} + {t("preferences.ai.apiKey")} + {t("preferences.ai.model")} +
+
+ +
+
+ {t("preferences.ai.provider")} + {t("preferences.ai.providerHint")} +
+
+ {settings.providers.map((entry) => ( + selectProvider(entry.id)} + /> + ))} +
+
+ +
+
+ + + + + {showBaseUrl ? ( + + ) : null} +
+
+ +
+ + + {notice ? {notice.text} : null} +
+ {info.hasStoredKey ? ( + + ) : null} + {hasOverrides ? ( + + ) : null} +
+ +
+ {t("preferences.ai.legendTitle")} + + + + + + {t("preferences.ai.legend")} +
+ + setConfirmReset(false)} + onConfirm={() => { setConfirmReset(false); void run("reset", async () => (await resetAiSettings()).settings); }} + /> + + ); +} diff --git a/src/components/views/DailyDigestView.tsx b/src/components/views/DailyDigestView.tsx index 35a442a..54bba21 100644 --- a/src/components/views/DailyDigestView.tsx +++ b/src/components/views/DailyDigestView.tsx @@ -93,7 +93,7 @@ export function DailyDigestView({ digests, period, onPeriodChange }: DailyDigest
{digest.ai.headline} - {digest.ai.model} + {digest.ai.provider ? `${digest.ai.provider} · ${digest.ai.model}` : digest.ai.model}
{digest.ai.briefing.map((item) =>

{item}

)} diff --git a/src/components/views/GoalsLoadingState.tsx b/src/components/views/GoalsLoadingState.tsx new file mode 100644 index 0000000..18b7a7f --- /dev/null +++ b/src/components/views/GoalsLoadingState.tsx @@ -0,0 +1,51 @@ +interface GoalsLoadingStateProps { + label: string; +} + +/** Layout-matched skeleton shown while the initial goals request is pending. */ +export function GoalsLoadingState({ label }: GoalsLoadingStateProps) { + return ( +
+ {label} + {[0, 1].map((card) => ( + + ))} +
+ ); +} diff --git a/src/components/views/GoalsView.tsx b/src/components/views/GoalsView.tsx new file mode 100644 index 0000000..d07e92e --- /dev/null +++ b/src/components/views/GoalsView.tsx @@ -0,0 +1,257 @@ +import { useMemo, useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { createGoal, deleteGoal, generateGoalAdvice } from "../../api/github"; +import { useI18n } from "../../i18n/I18nProvider"; +import { Avatar } from "../common/Avatar"; +import { ConfirmDialog } from "../common/ConfirmDialog"; +import { RepositoryPicker } from "../common/RepositoryPicker"; +import { GoalIcon } from "../common/Icons"; +import { GoalProposalsModal } from "../modals/GoalProposalsModal"; +import { GOAL_METRIC_DEFINITIONS, type GoalMetric, type GoalProposal, type RepositoryGoal } from "../../types/goals"; +import type { GhRepo } from "../../types/github"; +import { calculateGoalProgress, groupGoalsByRepository } from "../../utils/goals"; +import { formatNumber } from "../../utils/format"; +import { GoalsLoadingState } from "./GoalsLoadingState"; + +interface GoalsViewProps { + goals: RepositoryGoal[]; + repos: GhRepo[]; + loading: boolean; + onChange: () => Promise | void; +} + +const metricLabels = new Map(GOAL_METRIC_DEFINITIONS.map((metric) => [metric.id, metric.label])); + +function currentRepoValue(repo: GhRepo | undefined, metric: GoalMetric): number { + if (metric === "stars") return repo?.stargazerCount ?? 0; + if (metric === "forks") return repo?.forkCount ?? 0; + return 0; +} + +export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) { + const { t } = useI18n(); + const [repository, setRepository] = useState(""); + const [metric, setMetric] = useState("stars"); + const [targetValue, setTargetValue] = useState(""); + const [deadline, setDeadline] = useState(""); + const [saving, setSaving] = useState(false); + const [advisingId, setAdvisingId] = useState(null); + const [error, setError] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const [proposalTarget, setProposalTarget] = useState<{ goalId: string; index: number } | null>(null); + // Proposals fetched while the modal is open, so reopening it shows them without a round-trip. + const [proposalCache, setProposalCache] = useState>({}); + const navigate = useNavigate(); + const reposByName = useMemo(() => new Map(repos.map((repo) => [repo.nameWithOwner, repo])), [repos]); + const groupedGoals = useMemo(() => groupGoalsByRepository(goals), [goals]); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(""); + setSaving(true); + try { + await createGoal({ + repository, + metric, + targetValue: Number(targetValue), + currentValue: currentRepoValue(reposByName.get(repository), metric), + deadline, + }); + setTargetValue(""); + setDeadline(""); + await onChange(); + } catch (cause) { + setError((cause as Error).message); + } finally { + setSaving(false); + } + } + + async function remove(id: string) { + try { + await deleteGoal(id); + await onChange(); + } catch (cause) { + setError((cause as Error).message); + } + } + + async function advise(id: string) { + setAdvisingId(id); + setError(""); + try { + await generateGoalAdvice(id); + await onChange(); + } catch (cause) { + setError((cause as Error).message); + } finally { + setAdvisingId(null); + } + } + + return ( +
+
+
+ +
+

{t("goals.createTitle")}

+

{t("goals.createDescription")}

+
+
+
void submit(event)}> + + + + + +
+
+ + {error ?
{error}
: null} + {loading && !goals.length ? : null} + {!goals.length && !loading ?

{t("goals.emptyTitle")}

{t("goals.emptyText")}

: null} +
+ {groupedGoals.map((group) => { + const repo = reposByName.get(group.repository); + const completedCount = group.goals.filter((goal) => calculateGoalProgress(goal).completed).length; + return ( +
+
+
+ +
+ {t("goals.mission")} +

{group.repository}

+

{repo?.description || t("repo.noDescription")}

+
+
+
+ {completedCount}/{group.goals.length} + {t("goals.completedMissions")} +
+
+ +
+ {group.goals.map((goal) => { + const progress = calculateGoalProgress(goal); + return ( +
+
+ {metricLabels.get(goal.metric) ?? goal.metric} + +
+
+
+
{progress.percentage}%
+
+
+
{formatNumber(goal.currentValue)}/ {formatNumber(goal.targetValue)}
+
+ +
+
+ {progress.completed ? t("goals.completed") : t("goals.remaining", { count: formatNumber(progress.remaining) })} + {progress.overdue ? t("goals.overdue") : t("goals.daysLeft", { count: progress.daysRemaining })} +
+
+
+
+ ); + })} +
+ +
+
+
{t("goals.growthStudioEyebrow")}

{t("goals.growthStudio")}

+

{t("goals.growthStudioDescription")}

+
+
+ {group.goals.map((goal) => ( +
+
+
{metricLabels.get(goal.metric) ?? goal.metric}{t("goals.aiPlan")}
+ +
+ {!goal.aiEnabled ?

{t("goals.aiFallback")}

: null} +
+ {goal.suggestions.map((suggestion, index) => { + const hasProposals = Boolean(suggestion.proposals?.length || proposalCache[`${goal.id}:${index}`]); + return ( +
+ {suggestion.category} + {suggestion.title} + +

{suggestion.action}

+
+ ); + })} +
+
+ ))} +
+
+
+ ); + })} +
+ {t("goals.deleteMessage", { + metric: deleteTarget ? metricLabels.get(deleteTarget.metric) ?? deleteTarget.metric : "", + repo: deleteTarget?.repository ?? "", + })}

} + confirmLabel={t("common.remove")} + danger + icon={} + onCancel={() => setDeleteTarget(null)} + onConfirm={() => { + const id = deleteTarget?.id; + setDeleteTarget(null); + if (id) void remove(id); + }} + /> + {proposalTarget ? (() => { + const goal = goals.find((entry) => entry.id === proposalTarget.goalId); + const suggestion = goal?.suggestions[proposalTarget.index]; + if (!goal || !suggestion) return null; + const cached = proposalCache[`${goal.id}:${proposalTarget.index}`]; + return ( + setProposalTarget(null)} + onOpenPreferences={() => { setProposalTarget(null); navigate("/preferences#preferences-ai"); }} + onProposals={(proposals, generatedAt) => setProposalCache((prev) => ({ ...prev, [`${goal.id}:${proposalTarget.index}`]: { proposals, generatedAt } }))} + /> + ); + })() : null} +
+ ); +} diff --git a/src/components/views/PreferencesView.tsx b/src/components/views/PreferencesView.tsx new file mode 100644 index 0000000..6c57d22 --- /dev/null +++ b/src/components/views/PreferencesView.tsx @@ -0,0 +1,134 @@ +import { useI18n } from "../../i18n/I18nProvider"; +import type { Language } from "../../utils/i18n"; +import { AiIntegrationSettings } from "../preferences/AiIntegrationSettings"; + +type Theme = "dark" | "light" | "auto"; +type TextSize = "small" | "normal" | "large"; + +interface PreferencesViewProps { + theme: Theme; + textSize: TextSize; + hideArchivedNoise: boolean; + onThemeChange: (theme: Theme) => void; + onTextSizeChange: (textSize: TextSize) => void; + onHideArchivedNoiseChange: (hideArchivedNoise: boolean) => void; + onBack: () => void; +} + +const PaletteIcon = () => ( + +); + +const SparkIcon = () => ( + +); + +/** Full-page preferences, reachable at `/preferences`. */ +export function PreferencesView({ + theme, + textSize, + hideArchivedNoise, + onThemeChange, + onTextSizeChange, + onHideArchivedNoiseChange, + onBack, +}: PreferencesViewProps) { + const { language, languages, setLanguage, t } = useI18n(); + + return ( +
+
+
+

{t("preferences.title")}

+

{t("preferences.subtitle")}

+
+ +
+ +
+ + +
+
+
+ +
+

{t("preferences.appearance")}

+

{t("preferences.appearanceHint")}

+
+
+
+ +
+ {t("preferences.theme")} +
+ {(["dark", "light", "auto"] as const).map((entry) => ( + + ))} +
+
+
+ {t("preferences.textSize")} +
+ {(["small", "normal", "large"] as const).map((entry) => ( + + ))} +
+
+
+
+
+ {t("preferences.hideArchivedNoise")} + {t("preferences.hideArchivedNoiseHint")} +
+
+
+ +
+
+ +
+

{t("preferences.ai")}

+

{t("preferences.ai.hint")}

+
+
+ +
+
+
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 77430a5..9d9f2bc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -91,6 +91,49 @@ export const en = { "preferences.theme": "Theme", "preferences.textSize": "Text size", "preferences.hideArchivedNoise": "Hide archived repo noise (PRs/issues)", + "preferences.openPage": "All preferences", + "preferences.openPageMeta": "AI integration, appearance", + "preferences.appearance": "Appearance", + "preferences.back": "Back to dashboard", + "preferences.subtitle": "Language, theme, text size and the AI provider used for digests and goals.", + "preferences.sections": "Sections", + "preferences.appearanceHint": "How the dashboard looks on this device. These settings are stored in the browser.", + "preferences.ai.providerHint": "Pick the service that generates digests and action plans. Each provider keeps its own saved key and model.", + "preferences.ai.legendTitle": "Priority", + "preferences.ai.legend": "Database overrides environment, environment overrides defaults.", + "preferences.ai.keyTag.env": "env key", + "preferences.ai.keyTag.stored": "saved key", + "preferences.ai.keyTag.none": "no key", + "preferences.ai.active": "Active", + "preferences.ai.keyNote": "Keys saved here stay on the server and are only returned masked.", + "preferences.hideArchivedNoiseHint": "Skip issues and pull requests from archived repositories in the Inbox and lists.", + "preferences.ai.customHint": "Ollama, Mistral, Groq, LM Studio…", + "preferences.ai": "AI integration", + "preferences.ai.hint": "Used for digest narratives and Goals action plans. Values saved here override the server environment.", + "preferences.ai.provider": "Provider", + "preferences.ai.apiKey": "API key", + "preferences.ai.apiKeyOptional": "API key (optional)", + "preferences.ai.apiKeyKeep": "Leave empty to keep the current key", + "preferences.ai.apiKeyMissing": "No key configured", + "preferences.ai.model": "Model", + "preferences.ai.modelRequired": "Model name required", + "preferences.ai.baseUrl": "Base URL", + "preferences.ai.source.database": "Saved in database", + "preferences.ai.source.env": "From environment", + "preferences.ai.source.default": "Default", + "preferences.ai.source.none": "Not set", + "preferences.ai.envHint": "Environment variable: {name}", + "preferences.ai.statusReady": "Ready", + "preferences.ai.statusIncomplete": "Not configured", + "preferences.ai.save": "Save", + "preferences.ai.saved": "Saved", + "preferences.ai.removeKey": "Remove stored key", + "preferences.ai.reset": "Reset to environment", + "preferences.ai.resetConfirm": "Remove every AI setting saved in the database and use the environment configuration?", + "preferences.ai.test": "Test connection", + "preferences.ai.testing": "Testing…", + "preferences.ai.testOk": "OK · {model} · {ms} ms", + "preferences.ai.loadError": "Could not load AI settings", "textSize.small": "Small", "textSize.normal": "Normal", "textSize.large": "Large", @@ -103,6 +146,58 @@ export const en = { "tabs.ci": "CI", "tabs.digest": "Digest", "tabs.board": "Board", + "tabs.goals": "Goals", + "goals.createTitle": "Set a repository goal", + "goals.createDescription": "Track a measurable result and get an action plan based on repository activity.", + "goals.repository": "Repository", + "goals.chooseRepository": "Choose a repository", + "goals.searchRepository": "Search repositories by name, language, or description…", + "goals.metric": "Metric", + "goals.target": "Target", + "goals.deadline": "Deadline", + "goals.add": "Add goal", + "goals.emptyTitle": "No goals yet", + "goals.emptyText": "Create your first measurable repository goal above.", + "goals.deleteConfirm": "Delete this goal?", + "goals.deleteTitle": "Remove goal?", + "goals.deleteMessage": "The {metric} goal for {repo} will be permanently removed. Other goals for this repository will not be affected.", + "goals.completed": "Goal reached", + "goals.remaining": "{count} remaining", + "goals.overdue": "Overdue", + "goals.daysLeft": "{count} days left", + "goals.aiPlan": "Recommended actions", + "goals.mission": "Active growth mission", + "goals.completedMissions": "Goals reached", + "goals.growthStudioEyebrow": "AI-powered playbook", + "goals.growthStudio": "Growth studio", + "goals.growthStudioDescription": "Turn repository signals into campaigns, community moves, and complete social assets ready to ship.", + "goals.generateAdvice": "Create plan", + "goals.refreshAdvice": "Refresh plan", + "goals.proposals": "Proposals", + "goals.proposalsOpen": "Get proposals", + "goals.proposalsKind": "Recommended action", + "goals.proposalsIntro": "Ready-to-use drafts for this action, based on the README and current activity of {repo}.", + "goals.proposalsLoading": "Reading the project and drafting proposals…", + "goals.proposalsRegenerate": "Regenerate", + "goals.proposalsGeneratedAt": "Generated {time}", + "goals.proposalsNoAi": "Configure an AI provider in Preferences to get proposals.", + "goals.proposalsOpenPreferences": "Open preferences", + "goals.proposalsEmpty": "No proposals yet.", + "goals.proposalFormat.x-thread": "X thread", + "goals.proposalFormat.linkedin-post": "LinkedIn post", + "goals.proposalFormat.mastodon-post": "Mastodon post", + "goals.proposalFormat.post": "Post", + "goals.proposalFormat.issue": "Issue draft", + "goals.proposalFormat.discussion": "Discussion", + "goals.proposalFormat.email": "Email", + "goals.proposalFormat.checklist": "Checklist", + "goals.proposalFormat.message": "Message", + "goals.proposalFormat.doc": "Doc", + "goals.copyThread": "Copy thread", + "goals.copyPost": "Copy post {count}", + "common.copy": "Copy", + "common.copied": "Copied", + "goals.aiFallback": "No AI provider is configured. The plan will use built-in recommendations.", "summary.issues": "{count} issues", "summary.prs": "{count} PRs", "summary.repos": "{count} repos", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 8c66aa7..08dbeff 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -93,6 +93,49 @@ export const it: Record = { "preferences.theme": "Tema", "preferences.textSize": "Dimensione testo", "preferences.hideArchivedNoise": "Nascondi rumore repo archiviati (PR/issue)", + "preferences.openPage": "Tutte le preferenze", + "preferences.openPageMeta": "Integrazione AI, aspetto", + "preferences.appearance": "Aspetto", + "preferences.back": "Torna alla dashboard", + "preferences.subtitle": "Lingua, tema, dimensione del testo e il provider AI usato per digest e goal.", + "preferences.sections": "Sezioni", + "preferences.appearanceHint": "Come appare la dashboard su questo dispositivo. Queste impostazioni sono salvate nel browser.", + "preferences.ai.providerHint": "Scegli il servizio che genera digest e piani d'azione. Ogni provider conserva la propria chiave e il proprio modello.", + "preferences.ai.legendTitle": "Priorità", + "preferences.ai.legend": "Il database sovrascrive l'ambiente, l'ambiente sovrascrive i predefiniti.", + "preferences.ai.keyTag.env": "chiave env", + "preferences.ai.keyTag.stored": "chiave salvata", + "preferences.ai.keyTag.none": "nessuna chiave", + "preferences.ai.active": "Attivo", + "preferences.ai.keyNote": "Le chiavi salvate qui restano sul server e vengono restituite solo mascherate.", + "preferences.hideArchivedNoiseHint": "Escludi issue e pull request dei repository archiviati da Inbox ed elenchi.", + "preferences.ai.customHint": "Ollama, Mistral, Groq, LM Studio…", + "preferences.ai": "Integrazione AI", + "preferences.ai.hint": "Usata per le narrative del digest e i piani d'azione dei Goal. I valori salvati qui sovrascrivono l'ambiente del server.", + "preferences.ai.provider": "Provider", + "preferences.ai.apiKey": "API key", + "preferences.ai.apiKeyOptional": "API key (opzionale)", + "preferences.ai.apiKeyKeep": "Lascia vuoto per mantenere la chiave attuale", + "preferences.ai.apiKeyMissing": "Nessuna chiave configurata", + "preferences.ai.model": "Modello", + "preferences.ai.modelRequired": "Nome modello obbligatorio", + "preferences.ai.baseUrl": "Base URL", + "preferences.ai.source.database": "Salvato nel database", + "preferences.ai.source.env": "Da ambiente", + "preferences.ai.source.default": "Predefinito", + "preferences.ai.source.none": "Non impostato", + "preferences.ai.envHint": "Variabile d'ambiente: {name}", + "preferences.ai.statusReady": "Pronto", + "preferences.ai.statusIncomplete": "Non configurato", + "preferences.ai.save": "Salva", + "preferences.ai.saved": "Salvato", + "preferences.ai.removeKey": "Rimuovi chiave salvata", + "preferences.ai.reset": "Ripristina da ambiente", + "preferences.ai.resetConfirm": "Rimuovere tutte le impostazioni AI salvate nel database e usare la configurazione d'ambiente?", + "preferences.ai.test": "Prova connessione", + "preferences.ai.testing": "Verifica…", + "preferences.ai.testOk": "OK · {model} · {ms} ms", + "preferences.ai.loadError": "Impossibile caricare le impostazioni AI", "textSize.small": "Piccolo", "textSize.normal": "Normale", "textSize.large": "Grande", @@ -105,6 +148,58 @@ export const it: Record = { "tabs.ci": "CI", "tabs.digest": "Digest", "tabs.board": "Board", + "tabs.goals": "Obiettivi", + "goals.createTitle": "Imposta un obiettivo per la repository", + "goals.createDescription": "Monitora un risultato misurabile e ricevi un piano basato sull'attività della repository.", + "goals.repository": "Repository", + "goals.chooseRepository": "Scegli una repository", + "goals.searchRepository": "Cerca per nome, linguaggio o descrizione…", + "goals.metric": "Metrica", + "goals.target": "Obiettivo", + "goals.deadline": "Scadenza", + "goals.add": "Aggiungi obiettivo", + "goals.emptyTitle": "Nessun obiettivo", + "goals.emptyText": "Crea qui sopra il tuo primo obiettivo misurabile.", + "goals.deleteConfirm": "Eliminare questo obiettivo?", + "goals.deleteTitle": "Rimuovere il goal?", + "goals.deleteMessage": "Il goal {metric} di {repo} verrà rimosso definitivamente. Gli altri goal della repository non saranno modificati.", + "goals.completed": "Obiettivo raggiunto", + "goals.remaining": "Ne mancano {count}", + "goals.overdue": "Scaduto", + "goals.daysLeft": "{count} giorni rimasti", + "goals.aiPlan": "Interventi consigliati", + "goals.mission": "Missione growth attiva", + "goals.completedMissions": "Goal raggiunti", + "goals.growthStudioEyebrow": "Playbook potenziato dall'AI", + "goals.growthStudio": "Growth studio", + "goals.growthStudioDescription": "Trasforma i segnali della repository in campagne, iniziative community e contenuti social completi pronti da pubblicare.", + "goals.generateAdvice": "Crea piano", + "goals.refreshAdvice": "Aggiorna piano", + "goals.proposals": "Proposte", + "goals.proposalsOpen": "Ottieni proposte", + "goals.proposalsKind": "Intervento consigliato", + "goals.proposalsIntro": "Bozze pronte all'uso per questo intervento, basate sul README e sull'attività attuale di {repo}.", + "goals.proposalsLoading": "Sto leggendo il progetto e preparando le proposte…", + "goals.proposalsRegenerate": "Rigenera", + "goals.proposalsGeneratedAt": "Generate {time}", + "goals.proposalsNoAi": "Configura un provider AI nelle Preferenze per ottenere proposte.", + "goals.proposalsOpenPreferences": "Apri preferenze", + "goals.proposalsEmpty": "Nessuna proposta.", + "goals.proposalFormat.x-thread": "Thread X", + "goals.proposalFormat.linkedin-post": "Post LinkedIn", + "goals.proposalFormat.mastodon-post": "Post Mastodon", + "goals.proposalFormat.post": "Post", + "goals.proposalFormat.issue": "Bozza issue", + "goals.proposalFormat.discussion": "Discussione", + "goals.proposalFormat.email": "Email", + "goals.proposalFormat.checklist": "Checklist", + "goals.proposalFormat.message": "Messaggio", + "goals.proposalFormat.doc": "Documento", + "goals.copyThread": "Copia thread", + "goals.copyPost": "Copia post {count}", + "common.copy": "Copia", + "common.copied": "Copiato", + "goals.aiFallback": "Nessun provider AI configurato. Il piano userà suggerimenti integrati.", "summary.issues": "{count} issue", "summary.prs": "{count} PR", "summary.repos": "{count} repo", diff --git a/src/server/ai/client.ts b/src/server/ai/client.ts new file mode 100644 index 0000000..b67f13c --- /dev/null +++ b/src/server/ai/client.ts @@ -0,0 +1,212 @@ +import type { AiConnectionTest } from "../../types/ai"; +import { isAiConfigured, resolveAiConfig, type ResolvedAiConfig } from "./settings"; + +const REQUEST_TIMEOUT_MS = 60_000; + +export interface JsonSchema { + type: "object"; + additionalProperties?: boolean; + properties: Record; + required: string[]; +} + +export interface StructuredRequest { + /** System-level instructions describing the task. */ + instructions: string; + /** User content, usually the data to reason about. */ + input: string; + /** JSON schema of the expected answer; the object is returned parsed. */ + schema: JsonSchema; + schemaName: string; + maxOutputTokens: number; +} + +export interface StructuredResult { + provider: string; + model: string; + data: T; +} + +export class AiNotConfiguredError extends Error { + constructor() { + super("AI provider is not configured"); + this.name = "AiNotConfiguredError"; + } +} + +export class AiRequestError extends Error { + constructor(message: string, readonly status?: number) { + super(message); + this.name = "AiRequestError"; + } +} + +async function postJson(url: string, headers: Record, body: unknown, label: string): Promise { + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new AiRequestError(`${label} request failed: ${(error as Error).message}`); + } + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new AiRequestError(`${label} request failed with HTTP ${response.status}${summarizeError(detail)}`, response.status); + } + return response.json(); +} + +function summarizeError(body: string): string { + if (!body) return ""; + try { + const parsed = JSON.parse(body) as { error?: { message?: string } | string; message?: string }; + const message = typeof parsed.error === "string" ? parsed.error : parsed.error?.message ?? parsed.message; + return message ? `: ${message.slice(0, 200)}` : ""; + } catch { + return `: ${body.slice(0, 200)}`; + } +} + +/** Parses a JSON object out of a model answer, tolerating code fences and prose around it. */ +export function parseJsonAnswer(text: string): T { + const trimmed = text.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + // fall through to the lenient extraction below + } + const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed); + const candidate = fenced?.[1] ?? trimmed.slice(trimmed.indexOf("{"), trimmed.lastIndexOf("}") + 1); + try { + return JSON.parse(candidate) as T; + } catch { + throw new AiRequestError("AI answer was not valid JSON"); + } +} + +function schemaInstructions(request: StructuredRequest): string { + return `${request.instructions}\n\nAnswer with a single JSON object matching this JSON schema, without markdown or commentary:\n${JSON.stringify(request.schema)}`; +} + +async function callOpenAiChat(config: ResolvedAiConfig, request: StructuredRequest): Promise { + const headers: Record = {}; + if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; + if (config.provider.id === "openrouter") { + headers["HTTP-Referer"] = "https://github.com/debba/gitdeck"; + headers["X-Title"] = "Gitdeck"; + } + // OpenAI enforces the schema server-side. Other compatible servers vary in + // what they accept, so they get plain JSON mode plus the schema in the prompt. + const strict = config.provider.id === "openai"; + const json = await postJson(`${config.baseUrl}/chat/completions`, headers, { + model: config.model, + messages: [ + { role: "system", content: strict ? request.instructions : schemaInstructions(request) }, + { role: "user", content: request.input }, + ], + response_format: strict + ? { type: "json_schema", json_schema: { name: request.schemaName, strict: true, schema: request.schema } } + : { type: "json_object" }, + max_tokens: request.maxOutputTokens, + temperature: 0.4, + }, config.provider.label) as { choices?: Array<{ message?: { content?: string | Array<{ type?: string; text?: string }> } }> }; + const content = json.choices?.[0]?.message?.content; + const text = typeof content === "string" + ? content + : (content ?? []).map((part) => part.text ?? "").join(""); + if (!text.trim()) throw new AiRequestError(`${config.provider.label} returned an empty answer`); + return parseJsonAnswer(text); +} + +async function callAnthropic(config: ResolvedAiConfig, request: StructuredRequest): Promise { + const toolName = request.schemaName; + const json = await postJson(`${config.baseUrl}/v1/messages`, { + "x-api-key": config.apiKey ?? "", + "anthropic-version": "2023-06-01", + }, { + model: config.model, + max_tokens: request.maxOutputTokens, + system: request.instructions, + messages: [{ role: "user", content: request.input }], + tools: [{ name: toolName, description: "Record the structured answer.", input_schema: request.schema }], + tool_choice: { type: "tool", name: toolName }, + }, config.provider.label) as { content?: Array<{ type?: string; name?: string; input?: unknown; text?: string }> }; + const toolUse = (json.content ?? []).find((block) => block.type === "tool_use" && block.name === toolName); + if (toolUse?.input && typeof toolUse.input === "object") return toolUse.input as T; + const text = (json.content ?? []).map((block) => block.text ?? "").join(""); + if (!text.trim()) throw new AiRequestError(`${config.provider.label} returned an empty answer`); + return parseJsonAnswer(text); +} + +/** Gemini accepts an OpenAPI subset: strip keywords it rejects. */ +function toGeminiSchema(schema: unknown): unknown { + if (Array.isArray(schema)) return schema.map(toGeminiSchema); + if (!schema || typeof schema !== "object") return schema; + const out: Record = {}; + for (const [key, value] of Object.entries(schema as Record)) { + if (key === "additionalProperties") continue; + out[key] = toGeminiSchema(value); + } + return out; +} + +async function callGemini(config: ResolvedAiConfig, request: StructuredRequest): Promise { + const url = `${config.baseUrl}/models/${encodeURIComponent(config.model ?? "")}:generateContent`; + const json = await postJson(url, { "x-goog-api-key": config.apiKey ?? "" }, { + systemInstruction: { parts: [{ text: request.instructions }] }, + contents: [{ role: "user", parts: [{ text: request.input }] }], + generationConfig: { + responseMimeType: "application/json", + responseSchema: toGeminiSchema(request.schema), + maxOutputTokens: request.maxOutputTokens, + temperature: 0.4, + }, + }, config.provider.label) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }; + const text = (json.candidates?.[0]?.content?.parts ?? []).map((part) => part.text ?? "").join(""); + if (!text.trim()) throw new AiRequestError(`${config.provider.label} returned an empty answer`); + return parseJsonAnswer(text); +} + +/** + * Runs a structured-output request against the configured provider. Callers + * describe the task once; the provider-specific wire format is handled here. + */ +export async function generateStructured(request: StructuredRequest, config = resolveAiConfig()): Promise> { + if (!isAiConfigured(config) || !config.model) throw new AiNotConfiguredError(); + let data: T; + switch (config.provider.wire) { + case "anthropic-messages": + data = await callAnthropic(config, request); + break; + case "gemini-generate": + data = await callGemini(config, request); + break; + default: + data = await callOpenAiChat(config, request); + } + return { provider: config.provider.id, model: config.model, data }; +} + +/** Sends a minimal request to verify credentials, model name and endpoint. */ +export async function testAiConnection(): Promise { + const config = resolveAiConfig(); + const startedAt = Date.now(); + const result = await generateStructured<{ reply: string }>({ + instructions: "You are a connectivity check. Reply with the single word OK.", + input: "ping", + schema: { type: "object", additionalProperties: false, properties: { reply: { type: "string" } }, required: ["reply"] }, + schemaName: "connectivity_check", + maxOutputTokens: 200, + }, config); + return { + ok: true, + provider: result.provider as AiConnectionTest["provider"], + model: result.model, + latencyMs: Date.now() - startedAt, + reply: String(result.data.reply ?? "").slice(0, 80), + }; +} diff --git a/src/server/ai/providers.ts b/src/server/ai/providers.ts new file mode 100644 index 0000000..28382ee --- /dev/null +++ b/src/server/ai/providers.ts @@ -0,0 +1,83 @@ +import type { AiProviderId } from "../../types/ai"; + +export type AiWireFormat = "openai-chat" | "anthropic-messages" | "gemini-generate"; + +export interface AiProviderDefinition { + id: AiProviderId; + label: string; + wire: AiWireFormat; + /** Environment variables checked, in order, for this provider's API key. */ + envKeyNames: string[]; + /** Environment variables checked, in order, for this provider's model. */ + envModelNames: string[]; + defaultModel: string | null; + defaultBaseUrl: string; + requiresApiKey: boolean; + supportsBaseUrl: boolean; +} + +export const AI_PROVIDERS: Record = { + openai: { + id: "openai", + label: "OpenAI", + wire: "openai-chat", + envKeyNames: ["OPENAI_API_KEY"], + // OPENAI_DIGEST_MODEL is the pre-multi-provider name, still honoured. + envModelNames: ["OPENAI_MODEL", "OPENAI_DIGEST_MODEL"], + defaultModel: "gpt-4.1-mini", + defaultBaseUrl: "https://api.openai.com/v1", + requiresApiKey: true, + supportsBaseUrl: false, + }, + anthropic: { + id: "anthropic", + label: "Anthropic", + wire: "anthropic-messages", + envKeyNames: ["ANTHROPIC_API_KEY"], + envModelNames: ["ANTHROPIC_MODEL"], + defaultModel: "claude-sonnet-5", + defaultBaseUrl: "https://api.anthropic.com", + requiresApiKey: true, + supportsBaseUrl: false, + }, + gemini: { + id: "gemini", + label: "Google Gemini", + wire: "gemini-generate", + envKeyNames: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + envModelNames: ["GEMINI_MODEL"], + defaultModel: "gemini-2.5-flash", + defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", + requiresApiKey: true, + supportsBaseUrl: false, + }, + openrouter: { + id: "openrouter", + label: "OpenRouter", + wire: "openai-chat", + envKeyNames: ["OPENROUTER_API_KEY"], + envModelNames: ["OPENROUTER_MODEL"], + defaultModel: "openai/gpt-4.1-mini", + defaultBaseUrl: "https://openrouter.ai/api/v1", + requiresApiKey: true, + supportsBaseUrl: false, + }, + custom: { + id: "custom", + label: "OpenAI-compatible", + wire: "openai-chat", + envKeyNames: [], + envModelNames: [], + defaultModel: null, + defaultBaseUrl: "http://localhost:11434/v1", + requiresApiKey: false, + supportsBaseUrl: true, + }, +}; + +/** Order used to auto-detect the provider when `AI_PROVIDER` is not set. */ +export const AI_PROVIDER_ORDER: AiProviderId[] = ["openai", "anthropic", "gemini", "openrouter", "custom"]; + +export function isAiProviderId(value: unknown): value is AiProviderId { + return typeof value === "string" && value in AI_PROVIDERS; +} diff --git a/src/server/ai/settings.ts b/src/server/ai/settings.ts new file mode 100644 index 0000000..3127616 --- /dev/null +++ b/src/server/ai/settings.ts @@ -0,0 +1,180 @@ +import type { AiProviderId, AiProviderInfo, AiSettingSource, AiSettingsSummary, AiSettingsUpdate } from "../../types/ai"; +import { deletePreference, getPreference, setPreference } from "../preferenceStore"; +import { AI_PROVIDER_ORDER, AI_PROVIDERS, isAiProviderId, type AiProviderDefinition } from "./providers"; + +const SCOPE = "ai"; +const PROVIDER_KEY = "provider"; + +interface StoredProviderOverrides { + apiKey?: string; + model?: string; + baseUrl?: string; +} + +/** Fully resolved configuration used to talk to a provider. */ +export interface ResolvedAiConfig { + provider: AiProviderDefinition; + providerSource: AiSettingSource; + apiKey: string | null; + apiKeySource: AiSettingSource; + model: string | null; + modelSource: AiSettingSource; + baseUrl: string; + baseUrlSource: AiSettingSource; +} + +function providerKey(id: AiProviderId): string { + return `provider:${id}`; +} + +function readStored(id: AiProviderId): StoredProviderOverrides { + const stored = getPreference(SCOPE, providerKey(id), null); + return stored && typeof stored === "object" ? stored : {}; +} + +function envValue(names: string[]): string | null { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + return null; +} + +function envApiKey(provider: AiProviderDefinition, explicit: boolean): string | null { + // AI_API_KEY is a generic key that only applies to the provider selected via + // AI_PROVIDER, otherwise it would be ambiguous which service it belongs to. + return envValue(provider.envKeyNames) ?? (explicit ? envValue(["AI_API_KEY"]) : null); +} + +function resolveProvider(): { provider: AiProviderDefinition; source: AiSettingSource; explicitEnv: boolean } { + const stored = getPreference(SCOPE, PROVIDER_KEY, null); + const envProvider = process.env.AI_PROVIDER?.trim().toLowerCase(); + const explicitEnv = isAiProviderId(envProvider); + if (isAiProviderId(stored)) return { provider: AI_PROVIDERS[stored], source: "database", explicitEnv: explicitEnv && envProvider === stored }; + if (explicitEnv) return { provider: AI_PROVIDERS[envProvider], source: "env", explicitEnv: true }; + const detected = AI_PROVIDER_ORDER.find((id) => envValue(AI_PROVIDERS[id].envKeyNames)); + if (detected) return { provider: AI_PROVIDERS[detected], source: "env", explicitEnv: false }; + return { provider: AI_PROVIDERS.openai, source: "default", explicitEnv: false }; +} + +/** + * Resolves the effective AI configuration. Values saved in SQLite win over the + * environment, which in turn wins over built-in defaults; every field records + * the layer it came from so the UI can show it. + */ +export function resolveAiConfig(): ResolvedAiConfig { + const { provider, source: providerSource, explicitEnv } = resolveProvider(); + const stored = readStored(provider.id); + + const storedKey = stored.apiKey?.trim() || null; + const envKey = envApiKey(provider, explicitEnv); + const apiKey = storedKey ?? envKey; + const apiKeySource: AiSettingSource = storedKey ? "database" : envKey ? "env" : "none"; + + const storedModel = stored.model?.trim() || null; + const envModel = envValue(provider.envModelNames) ?? (explicitEnv ? envValue(["AI_MODEL"]) : null); + const model = storedModel ?? envModel ?? provider.defaultModel; + const modelSource: AiSettingSource = storedModel ? "database" : envModel ? "env" : model ? "default" : "none"; + + const storedBaseUrl = stored.baseUrl?.trim() || null; + const envBaseUrl = explicitEnv ? envValue(["AI_BASE_URL"]) : null; + const baseUrl = (storedBaseUrl ?? envBaseUrl ?? provider.defaultBaseUrl).replace(/\/+$/, ""); + const baseUrlSource: AiSettingSource = storedBaseUrl ? "database" : envBaseUrl ? "env" : "default"; + + return { provider, providerSource, apiKey, apiKeySource, model, modelSource, baseUrl, baseUrlSource }; +} + +export function isAiConfigured(config = resolveAiConfig()): boolean { + if (!config.model) return false; + return Boolean(config.apiKey) || !config.provider.requiresApiKey; +} + +export function maskSecret(value: string): string { + if (value.length <= 8) return "••••"; + return `${value.slice(0, 3)}…${value.slice(-4)}`; +} + +function describeProvider(definition: AiProviderDefinition): AiProviderInfo { + const stored = readStored(definition.id); + return { + id: definition.id, + label: definition.label, + envKeyName: definition.envKeyNames[0] ?? "AI_API_KEY", + defaultModel: definition.defaultModel, + defaultBaseUrl: definition.defaultBaseUrl, + requiresApiKey: definition.requiresApiKey, + supportsBaseUrl: definition.supportsBaseUrl, + hasEnvKey: Boolean(envValue(definition.envKeyNames)), + hasStoredKey: Boolean(stored.apiKey?.trim()), + storedModel: stored.model?.trim() || null, + storedBaseUrl: stored.baseUrl?.trim() || null, + }; +} + +export function summarizeAiSettings(): AiSettingsSummary { + const config = resolveAiConfig(); + return { + enabled: isAiConfigured(config), + provider: { value: config.provider.id, source: config.providerSource }, + apiKey: { + configured: Boolean(config.apiKey), + masked: config.apiKey ? maskSecret(config.apiKey) : null, + source: config.apiKeySource, + }, + model: { value: config.model, source: config.modelSource }, + baseUrl: { value: config.baseUrl, source: config.baseUrlSource }, + providers: AI_PROVIDER_ORDER.map((id) => describeProvider(AI_PROVIDERS[id])), + }; +} + +export class AiSettingsValidationError extends Error {} + +function validateUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new AiSettingsValidationError("invalid base URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new AiSettingsValidationError("base URL must use http or https"); + return value.replace(/\/+$/, ""); +} + +/** + * Persists overrides for one provider. Fields left `undefined` are untouched, + * empty strings remove the override so the environment value applies again. + */ +export function updateAiSettings(update: AiSettingsUpdate): AiSettingsSummary { + const providerId = update.provider ?? resolveAiConfig().provider.id; + if (!isAiProviderId(providerId)) throw new AiSettingsValidationError("unknown provider"); + if (update.provider !== undefined) setPreference(SCOPE, PROVIDER_KEY, providerId); + + const next: StoredProviderOverrides = { ...readStored(providerId) }; + if (update.apiKey !== undefined) { + const apiKey = String(update.apiKey).trim(); + if (apiKey) next.apiKey = apiKey; + else delete next.apiKey; + } + if (update.model !== undefined) { + const model = String(update.model).trim(); + if (model.length > 200) throw new AiSettingsValidationError("model name too long"); + if (model) next.model = model; + else delete next.model; + } + if (update.baseUrl !== undefined) { + const baseUrl = String(update.baseUrl).trim(); + if (baseUrl) next.baseUrl = validateUrl(baseUrl); + else delete next.baseUrl; + } + + if (Object.keys(next).length) setPreference(SCOPE, providerKey(providerId), next); + else deletePreference(SCOPE, providerKey(providerId)); + return summarizeAiSettings(); +} + +/** Removes every stored override so the environment configuration applies. */ +export function resetAiSettings(): AiSettingsSummary { + deletePreference(SCOPE, PROVIDER_KEY); + for (const id of AI_PROVIDER_ORDER) deletePreference(SCOPE, providerKey(id)); + return summarizeAiSettings(); +} diff --git a/src/server/aiDigest.ts b/src/server/aiDigest.ts new file mode 100644 index 0000000..7b52124 --- /dev/null +++ b/src/server/aiDigest.ts @@ -0,0 +1,83 @@ +import type { DailyDigestRecord } from "../utils/digests"; +import type { DailyRepoDigest } from "../types/github"; +import { generateStructured } from "./ai/client"; +import { isAiConfigured } from "./ai/settings"; + +interface AiDigestResult { + provider: string; + model: string; + headline: string; + briefing: string[]; + generatedAt: string; +} + +function buildDigestPrompt(record: DailyDigestRecord | DailyRepoDigest): string { + if ("repo" in record) { + return [ + `Date: ${record.date}`, + `Repository: ${record.repo}`, + `Stars: ${record.stars} (delta ${record.starsDelta >= 0 ? "+" : ""}${record.starsDelta})`, + `Forks: ${record.forks} (delta ${record.forksDelta >= 0 ? "+" : ""}${record.forksDelta})`, + `Open issues: ${record.issueCount} (delta ${record.issueDelta >= 0 ? "+" : ""}${record.issueDelta})`, + `Stale issues: ${record.staleIssueCount} (delta ${record.staleIssueDelta >= 0 ? "+" : ""}${record.staleIssueDelta})`, + `Security alerts: ${record.securityAlertsCount}`, + "Highlights:", + ...record.highlights, + "Momentum:", + ...(record.momentum.length ? record.momentum : ["None"]), + "Risks:", + ...(record.risks.length ? record.risks : ["None"]), + ].join("\n"); + } + + const topRepos = record.repos + .slice(0, 8) + .map((repo) => `${repo.repo}: stars ${repo.stars}, forks ${repo.forks}, open issues ${repo.issueCount}, stale ${repo.staleIssueCount}`) + .join("\n"); + + return [ + `Date: ${record.date}`, + `Tracked repositories: ${record.repoCount}`, + `Total stars: ${record.totalStars}`, + `Total forks: ${record.totalForks}`, + `Open issues: ${record.issueCount}`, + `Stale issues: ${record.staleIssueCount}`, + `Security alerts: ${record.securityAlertsCount} across ${record.securityReposCount} repos`, + "Repository snapshot:", + topRepos || "None", + ].join("\n"); +} + +export async function maybeGenerateAiDigest(record: DailyDigestRecord | DailyRepoDigest): Promise { + if (!isAiConfigured()) return null; + if (record.ai?.headline && record.ai?.briefing?.length) return record.ai as AiDigestResult; + + const result = await generateStructured<{ headline: string; briefing: string[] }>({ + instructions: "You write concise engineering daily digests. Return plain JSON with keys: headline (string), briefing (array of exactly 3 strings). Keep each string under 140 characters.", + input: buildDigestPrompt(record), + schemaName: "daily_digest", + schema: { + type: "object", + additionalProperties: false, + properties: { + headline: { type: "string" }, + briefing: { + type: "array", + items: { type: "string" }, + minItems: 3, + maxItems: 3, + }, + }, + required: ["headline", "briefing"], + }, + maxOutputTokens: 300, + }); + if (!result.data.headline || !Array.isArray(result.data.briefing) || !result.data.briefing.length) return null; + return { + provider: result.provider, + model: result.model, + headline: result.data.headline, + briefing: result.data.briefing.map(String), + generatedAt: new Date().toISOString(), + }; +} diff --git a/src/server/digests.ts b/src/server/digests.ts index 7d11dce..13c1a57 100644 --- a/src/server/digests.ts +++ b/src/server/digests.ts @@ -6,7 +6,7 @@ import { DATA_DIR, DIGESTS_PATH } from "./config"; import { getIssuesCached, getReposCached } from "./dashboardData"; import { sendJsonCacheable } from "./http"; import { fetchRepoSecuritySummary } from "./securityAlerts"; -import { maybeGenerateOpenAIDigest } from "./openaiDigest"; +import { maybeGenerateAiDigest } from "./aiDigest"; const MAX_DIGEST_DAYS = 120; @@ -82,7 +82,7 @@ export async function handleDailyDigests(req: IncomingMessage, res: ServerRespon const latest = records[records.length - 1]; if (latest && !latest.ai) { try { - latest.ai = await maybeGenerateOpenAIDigest(latest); + latest.ai = await maybeGenerateAiDigest(latest); await saveDigests(); } catch { // AI enrichment is optional and should never break digest delivery. @@ -108,7 +108,7 @@ export async function getLatestRepoDigest(repo: string): Promise 0), + current_value INTEGER NOT NULL DEFAULT 0 CHECK(current_value >= 0), + deadline TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + suggestions TEXT NOT NULL DEFAULT '[]', + suggestions_generated_at TEXT + ); + CREATE INDEX IF NOT EXISTS repository_goals_account_deadline + ON repository_goals(account_id, deadline); + `); +} + +function fromRow(row: GoalRow): Omit { + let suggestions: GoalSuggestion[] = []; + try { suggestions = JSON.parse(row.suggestions) as GoalSuggestion[]; } catch { /* ignore invalid legacy data */ } + return { + id: row.id, + accountId: row.account_id, + repository: row.repository, + metric: row.metric, + targetValue: row.target_value, + currentValue: row.current_value, + deadline: row.deadline, + createdAt: row.created_at, + updatedAt: row.updated_at, + suggestions, + suggestionsGeneratedAt: row.suggestions_generated_at, + }; +} + +export function listGoals(accountId: string): Array> { + ensureSchema(); + return all("SELECT * FROM repository_goals WHERE account_id = ? ORDER BY deadline, created_at", [accountId]).map(fromRow); +} + +export function findGoal(accountId: string, id: string): Omit | null { + ensureSchema(); + const row = get("SELECT * FROM repository_goals WHERE account_id = ? AND id = ?", [accountId, id]); + return row ? fromRow(row) : null; +} + +export function createGoal(input: { + accountId: string; + repository: string; + metric: GoalMetric; + targetValue: number; + currentValue?: number; + deadline: string; +}): Omit { + ensureSchema(); + const id = randomUUID(); + const now = new Date().toISOString(); + run( + `INSERT INTO repository_goals + (id, account_id, repository, metric, target_value, current_value, deadline, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, input.accountId, input.repository, input.metric, input.targetValue, input.currentValue ?? 0, input.deadline, now, now], + ); + return findGoal(input.accountId, id)!; +} + +export function updateGoalCurrentValue(accountId: string, id: string, currentValue: number): void { + ensureSchema(); + run("UPDATE repository_goals SET current_value = ?, updated_at = ? WHERE account_id = ? AND id = ?", [currentValue, new Date().toISOString(), accountId, id]); +} + +export function saveGoalSuggestions(accountId: string, id: string, suggestions: GoalSuggestion[]): void { + ensureSchema(); + const now = new Date().toISOString(); + run("UPDATE repository_goals SET suggestions = ?, suggestions_generated_at = ?, updated_at = ? WHERE account_id = ? AND id = ?", [JSON.stringify(suggestions), now, now, accountId, id]); +} + +/** Attaches generated proposals to one suggestion; other suggestions are left untouched. */ +export function saveGoalProposals(accountId: string, id: string, index: number, proposals: GoalProposal[], proposalsVersion: number): GoalSuggestion | null { + const goal = findGoal(accountId, id); + const suggestion = goal?.suggestions[index]; + if (!goal || !suggestion) return null; + const now = new Date().toISOString(); + const updated: GoalSuggestion = { ...suggestion, proposals, proposalsGeneratedAt: now, proposalsVersion }; + const suggestions = goal.suggestions.map((entry, position) => (position === index ? updated : entry)); + run("UPDATE repository_goals SET suggestions = ?, updated_at = ? WHERE account_id = ? AND id = ?", [JSON.stringify(suggestions), now, accountId, id]); + return updated; +} + +export function deleteGoal(accountId: string, id: string): boolean { + ensureSchema(); + return run("DELETE FROM repository_goals WHERE account_id = ? AND id = ?", [accountId, id]).changes > 0; +} diff --git a/src/server/goals.ts b/src/server/goals.ts new file mode 100644 index 0000000..d599f59 --- /dev/null +++ b/src/server/goals.ts @@ -0,0 +1,257 @@ +import type { GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; +import { calculateGoalProgress } from "../utils/goals"; +import { hasCompleteSocialSet, normalizeSocialProposals, SOCIAL_PROPOSAL_FORMATS } from "../utils/socialProposals"; +import { AiNotConfiguredError, AiRequestError, generateStructured } from "./ai/client"; +import { isAiConfigured } from "./ai/settings"; +import { getIssuesCached, getPullRequestsCached, getReposCached } from "./dashboardData"; +import { ghApiJson, restApi, restApiPaginate } from "./githubClient"; +import { updateGoalCurrentValue } from "./goalStore"; + +interface MetricResolver { + resolve(repository: string): Promise; +} + +/** Add a metric here to make it automatically refreshable by the Goals API. */ +const METRIC_RESOLVERS: Record = { + stars: { + async resolve(repository) { + const result = await getReposCached(false); + return result.ok ? result.repos.find((repo) => repo.nameWithOwner === repository)?.stargazerCount ?? null : null; + }, + }, + forks: { + async resolve(repository) { + const result = await getReposCached(false); + return result.ok ? result.repos.find((repo) => repo.nameWithOwner === repository)?.forkCount ?? null : null; + }, + }, + closed_prs: { + async resolve(repository) { + const query = encodeURIComponent(`repo:${repository} is:pr is:closed`); + const result = await ghApiJson(`/search/issues?q=${query}&per_page=1`); + return result.ok ? Number((result.data as { total_count?: number }).total_count ?? 0) : null; + }, + }, + downloads: { + async resolve(repository) { + const result = await restApiPaginate(`/repos/${repository}/releases?per_page=100`); + if (!result.ok) return null; + return (result.data as Array<{ assets?: Array<{ download_count?: number }> }>).reduce( + (total, release) => total + (release.assets ?? []).reduce((sum, asset) => sum + (asset.download_count ?? 0), 0), + 0, + ); + }, + }, +}; + +export async function refreshGoal(goal: Omit): Promise> { + try { + const currentValue = await METRIC_RESOLVERS[goal.metric].resolve(goal.repository); + if (currentValue === null || currentValue === goal.currentValue) return goal; + updateGoalCurrentValue(goal.accountId, goal.id, currentValue); + return { ...goal, currentValue, updatedAt: new Date().toISOString() }; + } catch { + return goal; + } +} + +function fallbackSuggestions(goal: Omit): GoalSuggestion[] { + const progress = calculateGoalProgress(goal); + return [ + { + category: "product", + title: "Turn demand into a visible roadmap", + action: "Review the most discussed open issues, label the top three requests and publish which one will ship next.", + }, + { + category: "community", + title: "Reduce contribution friction", + action: "Triage unanswered issues and small PRs, add good-first-issue labels, and document one concrete contribution path.", + }, + { + category: "marketing", + title: "Publish a complete X launch thread", + action: `Tell the story of ${goal.repository} in a 5–7 post X thread: open with a concrete hook, show what the project solves, highlight recent work, share the ${progress.percentage}% goal progress, and close with one clear call to action.`, + }, + ]; +} + +export async function generateGoalSuggestions(goal: Omit): Promise { + if (!isAiConfigured()) return fallbackSuggestions(goal); + const [issuesResult, prsResult, reposResult] = await Promise.all([ + getIssuesCached(false), + getPullRequestsCached(false), + getReposCached(false), + ]); + const issues = issuesResult.ok ? issuesResult.issues.filter((item) => item.repository.nameWithOwner === goal.repository) : []; + const prs = prsResult.ok ? prsResult.pullRequests.filter((item) => item.repository.nameWithOwner === goal.repository) : []; + const repo = reposResult.ok ? reposResult.repos.find((item) => item.nameWithOwner === goal.repository) : null; + const progress = calculateGoalProgress(goal); + const staleIssues = issues.filter((item) => Date.now() - new Date(item.updatedAt).getTime() > 30 * 86_400_000).length; + + const result = await generateStructured<{ suggestions: GoalSuggestion[] }>({ + instructions: "Act as an open-source growth and social strategist. Give specific, ethical actions grounded in the supplied activity. Include at least one substantial social campaign idea designed as a complete 5–7 post X thread, not a generic one-line post. Give it a strong hook, a useful narrative arc, concrete project details, and one clear call to action. Return JSON only.", + input: JSON.stringify({ + repository: goal.repository, + description: repo?.description, + metric: goal.metric, + current: goal.currentValue, + target: goal.targetValue, + deadline: goal.deadline, + percentage: progress.percentage, + openIssues: issues.length, + staleIssues, + openPullRequests: prs.length, + recentIssueTitles: issues.slice(0, 8).map((item) => item.title), + recentPullRequestTitles: prs.slice(0, 5).map((item) => item.title), + }), + schemaName: "goal_actions", + schema: { + type: "object", + additionalProperties: false, + properties: { + suggestions: { + type: "array", + minItems: 3, + maxItems: 5, + items: { + type: "object", + additionalProperties: false, + properties: { + category: { type: "string", enum: ["product", "community", "engineering", "marketing"] }, + title: { type: "string" }, + action: { type: "string" }, + }, + required: ["category", "title", "action"], + }, + }, + }, + required: ["suggestions"], + }, + maxOutputTokens: 900, + }); + const suggestions = Array.isArray(result.data.suggestions) ? result.data.suggestions : []; + return suggestions.length ? suggestions : fallbackSuggestions(goal); +} + +export const SOCIAL_PROPOSALS_VERSION = 2; +const README_EXCERPT_CHARS = 7000; + +interface ReleaseSignal { + name?: string | null; + tag_name?: string; + html_url?: string; + published_at?: string | null; + body?: string | null; +} + +async function fetchReleaseSignals(repository: string): Promise { + try { + const result = await restApi(`/repos/${repository}/releases?per_page=3`); + return result.ok && Array.isArray(result.data) ? result.data.slice(0, 3) : []; + } catch { + return []; + } +} + +async function fetchReadmeExcerpt(repository: string): Promise { + try { + const result = await restApi<{ content?: string; encoding?: string }>(`/repos/${repository}/readme`); + if (!result.ok || !result.data?.content) return null; + const text = result.data.encoding === "base64" ? Buffer.from(result.data.content, "base64").toString("utf-8") : result.data.content; + return text.replace(/\r/g, "").trim().slice(0, README_EXCERPT_CHARS) || null; + } catch { + return null; + } +} + +/** + * Turns one recommended action into concrete, ready-to-use deliverables + * (posts, issue drafts, checklists…) grounded in the repository's README and + * current activity. Requires a configured AI provider. + */ +export async function generateGoalProposals(goal: Omit, suggestion: GoalSuggestion): Promise { + if (!isAiConfigured()) throw new AiNotConfiguredError(); + const [issuesResult, prsResult, reposResult, readme, releases] = await Promise.all([ + getIssuesCached(false), + getPullRequestsCached(false), + getReposCached(false), + fetchReadmeExcerpt(goal.repository), + fetchReleaseSignals(goal.repository), + ]); + const issues = issuesResult.ok ? issuesResult.issues.filter((item) => item.repository.nameWithOwner === goal.repository) : []; + const prs = prsResult.ok ? prsResult.pullRequests.filter((item) => item.repository.nameWithOwner === goal.repository) : []; + const repo = reposResult.ok ? reposResult.repos.find((item) => item.nameWithOwner === goal.repository) : null; + const progress = calculateGoalProgress(goal); + + const context = { + generatedOn: new Date().toISOString().slice(0, 10), + repository: goal.repository, + repositoryUrl: repo?.url ?? null, + visibility: repo?.visibility ?? null, + description: repo?.description ?? null, + primaryLanguage: repo?.primaryLanguage?.name ?? null, + verifiedMetrics: { stars: repo?.stargazerCount ?? null, forks: repo?.forkCount ?? null }, + goal: { metric: goal.metric, current: goal.currentValue, target: goal.targetValue, deadline: goal.deadline, percentage: progress.percentage }, + recommendedAngle: { category: suggestion.category, title: suggestion.title, description: suggestion.action }, + openIssues: issues.slice(0, 10).map((item) => ({ title: item.title, url: item.url, updatedAt: item.updatedAt, labels: item.labels.map((label) => label.name) })), + openPullRequests: prs.slice(0, 6).map((item) => ({ title: item.title, url: item.url, updatedAt: item.updatedAt, isDraft: item.isDraft })), + releases: releases.map((release) => ({ + name: release.name || release.tag_name || null, + url: release.html_url ?? null, + publishedAt: release.published_at ?? null, + notesExcerpt: release.body?.replace(/\s+/g, " ").trim().slice(0, 500) || null, + })), + readmeExcerpt: readme, + }; + const instructions = [ + "You are a senior open-source social strategist. Create publishable social copy, not an operational plan.", + "Choose one clear, credible campaign angle from the recommended action and adapt it to each platform and its audience.", + "Use only facts explicitly present in the input. Never invent users, benefits, benchmarks, quotes, release recency, roadmap commitments, or issue status. Treat issue and PR titles only as themes, not proof that work shipped. If evidence is thin, write a transparent invitation to try or contribute rather than making a claim.", + "Write in the main natural language of the README (English if unclear). Keep the project's own terminology and avoid generic AI phrases, hype, clickbait, fake urgency, and engagement bait.", + "Return exactly three distinct assets: one 'x-thread', one 'linkedin-post', and one 'mastodon-post'. Each must work standalone and include the supplied repository URL when it is public and available.", + "The X thread needs 5–7 ordered posts in threadPosts, each at most 280 Unicode characters. Build a coherent arc: specific hook, problem, project approach, one or two verified details, then one relevant CTA in the final post. Use at most two hashtags across the whole thread. Set content to the same posts in order.", + "The LinkedIn post should be 700–1400 characters when the evidence supports it, use short paragraphs, speak to a professional technical audience, and use at most three hashtags. Do not imitate X-thread fragments.", + "The Mastodon post must be at most 500 characters, direct and community-oriented, with at most two relevant hashtags and no engagement bait.", + "For both standalone posts threadPosts must be empty. Give each asset a concrete title. In summary, state the intended audience and the evidence-led angle in one sentence. Output JSON only.", + ].join(" "); + const schema = { + type: "object" as const, + additionalProperties: false, + properties: { + proposals: { + type: "array", + minItems: 3, + maxItems: 3, + items: { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + format: { type: "string", enum: [...SOCIAL_PROPOSAL_FORMATS] }, + summary: { type: "string" }, + content: { type: "string" }, + threadPosts: { type: "array", minItems: 0, maxItems: 7, items: { type: "string", maxLength: 280 } }, + }, + required: ["title", "format", "summary", "content", "threadPosts"], + }, + }, + }, + required: ["proposals"], + }; + + let feedback: string | null = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = await generateStructured<{ proposals: GoalProposal[] }>({ + instructions, + input: JSON.stringify({ ...context, validationFeedback: feedback }), + schemaName: "social_goal_proposals", + schema, + maxOutputTokens: 3600, + }); + const proposals = normalizeSocialProposals(result.data.proposals); + if (proposals.length === 3 && hasCompleteSocialSet(proposals)) return proposals; + feedback = "The previous answer was not publishable. Return all three required formats exactly once; use 5–7 X posts of at most 280 characters, LinkedIn content of at most 3000 characters, and Mastodon content of at most 500 characters."; + } + throw new AiRequestError("AI returned incomplete or platform-invalid social proposals"); +} diff --git a/src/server/openaiDigest.ts b/src/server/openaiDigest.ts deleted file mode 100644 index 5073b99..0000000 --- a/src/server/openaiDigest.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { DailyDigestRecord } from "../utils/digests"; -import type { DailyRepoDigest } from "../types/github"; - -const OPENAI_API_URL = "https://api.openai.com/v1/responses"; -const OPENAI_DIGEST_MODEL = process.env.OPENAI_DIGEST_MODEL ?? "gpt-4.1-mini"; - -interface OpenAIDigestResult { - model: string; - headline: string; - briefing: string[]; - generatedAt: string; -} - -function hasOpenAIConfig(): boolean { - return Boolean(process.env.OPENAI_API_KEY); -} - -function buildDigestPrompt(record: DailyDigestRecord | DailyRepoDigest): string { - if ("repo" in record) { - return [ - `Date: ${record.date}`, - `Repository: ${record.repo}`, - `Stars: ${record.stars} (delta ${record.starsDelta >= 0 ? "+" : ""}${record.starsDelta})`, - `Forks: ${record.forks} (delta ${record.forksDelta >= 0 ? "+" : ""}${record.forksDelta})`, - `Open issues: ${record.issueCount} (delta ${record.issueDelta >= 0 ? "+" : ""}${record.issueDelta})`, - `Stale issues: ${record.staleIssueCount} (delta ${record.staleIssueDelta >= 0 ? "+" : ""}${record.staleIssueDelta})`, - `Security alerts: ${record.securityAlertsCount} across ${record.securityReposCount} repos`, - "Highlights:", - ...record.highlights, - "Momentum:", - ...(record.momentum.length ? record.momentum : ["None"]), - "Risks:", - ...(record.risks.length ? record.risks : ["None"]), - ].join("\n"); - } - - const topRepos = record.repos - .slice(0, 8) - .map((repo) => `${repo.repo}: stars ${repo.stars}, forks ${repo.forks}, open issues ${repo.issueCount}, stale ${repo.staleIssueCount}`) - .join("\n"); - - return [ - `Date: ${record.date}`, - `Tracked repositories: ${record.repoCount}`, - `Total stars: ${record.totalStars}`, - `Total forks: ${record.totalForks}`, - `Open issues: ${record.issueCount}`, - `Stale issues: ${record.staleIssueCount}`, - `Security alerts: ${record.securityAlertsCount} across ${record.securityReposCount} repos`, - "Repository snapshot:", - topRepos || "None", - ].join("\n"); -} - -function extractText(response: { output?: Array<{ type?: string; content?: Array<{ type?: string; text?: string }> }> }): string { - return (response.output || []) - .flatMap((item) => item.type === "message" ? (item.content || []) : []) - .filter((item) => item.type === "output_text" && item.text) - .map((item) => item.text) - .join("\n") - .trim(); -} - -export async function maybeGenerateOpenAIDigest(record: DailyDigestRecord | DailyRepoDigest): Promise { - if (!hasOpenAIConfig()) return null; - if (record.ai?.headline && record.ai?.briefing?.length) return record.ai; - - const response = await fetch(OPENAI_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`, - }, - body: JSON.stringify({ - model: OPENAI_DIGEST_MODEL, - instructions: "You write concise engineering daily digests. Return plain JSON with keys: headline (string), briefing (array of exactly 3 strings). Keep each string under 140 characters.", - input: buildDigestPrompt(record), - text: { - format: { - type: "json_schema", - name: "daily_digest", - schema: { - type: "object", - additionalProperties: false, - properties: { - headline: { type: "string" }, - briefing: { - type: "array", - items: { type: "string" }, - minItems: 3, - maxItems: 3, - }, - }, - required: ["headline", "briefing"], - }, - }, - }, - max_output_tokens: 300, - store: false, - }), - }); - - if (!response.ok) { - throw new Error(`OpenAI digest request failed with HTTP ${response.status}`); - } - - const json = await response.json() as { output?: Array<{ type?: string; content?: Array<{ type?: string; text?: string }> }> }; - const text = extractText(json); - if (!text) return null; - - const parsed = JSON.parse(text) as { headline: string; briefing: string[] }; - return { - model: OPENAI_DIGEST_MODEL, - headline: parsed.headline, - briefing: parsed.briefing, - generatedAt: new Date().toISOString(), - }; -} diff --git a/src/server/preferenceStore.ts b/src/server/preferenceStore.ts new file mode 100644 index 0000000..ad7a072 --- /dev/null +++ b/src/server/preferenceStore.ts @@ -0,0 +1,46 @@ +import { get, getDatabase, run } from "./sqlite"; + +interface PreferenceRow { + value: string; +} + +function ensureSchema(): void { + getDatabase().exec(` + CREATE TABLE IF NOT EXISTS preferences ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (scope, key) + ) + `); +} + +/** + * Tiny JSON preference store. New features can persist any serialisable value + * without adding another file or schema migration. + */ +export function setPreference(scope: string, key: string, value: T): void { + ensureSchema(); + run( + `INSERT INTO preferences (scope, key, value, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + [scope, key, JSON.stringify(value), new Date().toISOString()], + ); +} + +export function getPreference(scope: string, key: string, fallback: T): T { + ensureSchema(); + const row = get("SELECT value FROM preferences WHERE scope = ? AND key = ?", [scope, key]); + if (!row) return fallback; + try { + return JSON.parse(row.value) as T; + } catch { + return fallback; + } +} + +export function deletePreference(scope: string, key: string): void { + ensureSchema(); + run("DELETE FROM preferences WHERE scope = ? AND key = ?", [scope, key]); +} diff --git a/src/server/routes/ai.ts b/src/server/routes/ai.ts new file mode 100644 index 0000000..40774cd --- /dev/null +++ b/src/server/routes/ai.ts @@ -0,0 +1,58 @@ +import { getActive as getActiveAccount } from "../accountStore"; +import { AiNotConfiguredError, AiRequestError, testAiConnection } from "../ai/client"; +import { isAiProviderId } from "../ai/providers"; +import { AiSettingsValidationError, resetAiSettings, summarizeAiSettings, updateAiSettings } from "../ai/settings"; +import { parseJsonBody, sendJson } from "../http"; +import type { AppRouter, RouteContext } from "../router"; +import type { AiSettingsUpdate } from "../../types/ai"; + +async function requireAccount(ctx: RouteContext): Promise { + const account = await getActiveAccount(); + if (!account) sendJson(ctx.res, 401, { ok: false, needsAuth: true, error: "authentication required" }); + return Boolean(account); +} + +async function read(ctx: RouteContext): Promise { + if (!(await requireAccount(ctx))) return; + sendJson(ctx.res, 200, { ok: true, settings: summarizeAiSettings() }); +} + +async function update(ctx: RouteContext): Promise { + if (!(await requireAccount(ctx))) return; + const body = await parseJsonBody>>(ctx.req, ctx.res); + if (!body) return; + if (body.provider !== undefined && !isAiProviderId(body.provider)) return sendJson(ctx.res, 400, { ok: false, error: "unknown provider" }); + for (const field of ["apiKey", "model", "baseUrl"] as const) { + if (body[field] !== undefined && typeof body[field] !== "string") return sendJson(ctx.res, 400, { ok: false, error: `${field} must be a string` }); + } + try { + const settings = updateAiSettings(body as AiSettingsUpdate); + sendJson(ctx.res, 200, { ok: true, settings }); + } catch (error) { + if (error instanceof AiSettingsValidationError) return sendJson(ctx.res, 400, { ok: false, error: error.message }); + throw error; + } +} + +async function reset(ctx: RouteContext): Promise { + if (!(await requireAccount(ctx))) return; + sendJson(ctx.res, 200, { ok: true, settings: resetAiSettings() }); +} + +async function test(ctx: RouteContext): Promise { + if (!(await requireAccount(ctx))) return; + try { + sendJson(ctx.res, 200, await testAiConnection()); + } catch (error) { + if (error instanceof AiNotConfiguredError) return sendJson(ctx.res, 409, { ok: false, error: error.message }); + const status = error instanceof AiRequestError ? 502 : 500; + sendJson(ctx.res, status, { ok: false, error: (error as Error).message }); + } +} + +export function registerAiRoutes(router: AppRouter): void { + router.get("/api/ai/settings", read); + router.on("PUT", "/api/ai/settings", update); + router.delete("/api/ai/settings", reset); + router.post("/api/ai/settings/test", test); +} diff --git a/src/server/routes/goals.ts b/src/server/routes/goals.ts new file mode 100644 index 0000000..77fa9fd --- /dev/null +++ b/src/server/routes/goals.ts @@ -0,0 +1,100 @@ +import { getActive as getActiveAccount } from "../accountStore"; +import { createGoal, deleteGoal, findGoal, listGoals, saveGoalProposals, saveGoalSuggestions } from "../goalStore"; +import { isAiConfigured } from "../ai/settings"; +import { AiNotConfiguredError, AiRequestError } from "../ai/client"; +import { generateGoalProposals, generateGoalSuggestions, refreshGoal, SOCIAL_PROPOSALS_VERSION } from "../goals"; +import { parseJsonBody, sendJson } from "../http"; +import type { AppRouter, RouteContext } from "../router"; +import { GOAL_METRICS, type GoalMetric } from "../../types/goals"; +import { parseRepositoryName } from "../../utils/repository"; + +async function requireAccount(ctx: RouteContext) { + const account = await getActiveAccount(); + if (!account) sendJson(ctx.res, 401, { ok: false, needsAuth: true, error: "authentication required" }); + return account; +} + +async function list(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const goals = await Promise.all(listGoals(account.id).map(refreshGoal)); + sendJson(ctx.res, 200, { ok: true, goals: goals.map((goal) => ({ ...goal, aiEnabled: isAiConfigured() })) }); +} + +async function create(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const body = await parseJsonBody<{ repository?: string; metric?: string; targetValue?: number; currentValue?: number; deadline?: string }>(ctx.req, ctx.res); + if (!body) return; + const repository = body.repository?.trim() ?? ""; + const metric = body.metric as GoalMetric; + const targetValue = Number(body.targetValue); + const deadline = body.deadline ?? ""; + if (!parseRepositoryName(repository)) return sendJson(ctx.res, 400, { ok: false, error: "invalid repository" }); + if (!GOAL_METRICS.includes(metric)) return sendJson(ctx.res, 400, { ok: false, error: "invalid metric" }); + if (!Number.isSafeInteger(targetValue) || targetValue <= 0) return sendJson(ctx.res, 400, { ok: false, error: "target must be a positive integer" }); + if (!/^\d{4}-\d{2}-\d{2}$/.test(deadline) || Number.isNaN(Date.parse(deadline))) return sendJson(ctx.res, 400, { ok: false, error: "invalid deadline" }); + const initial = Number.isSafeInteger(body.currentValue) && Number(body.currentValue) >= 0 ? Number(body.currentValue) : 0; + let goal = await refreshGoal(createGoal({ accountId: account.id, repository, metric, targetValue, currentValue: initial, deadline })); + try { + const suggestions = await generateGoalSuggestions(goal); + saveGoalSuggestions(account.id, goal.id, suggestions); + goal = { ...goal, suggestions, suggestionsGeneratedAt: new Date().toISOString() }; + } catch { + // Goal creation must still succeed when the optional AI provider is unavailable. + } + sendJson(ctx.res, 201, { ok: true, goal: { ...goal, aiEnabled: isAiConfigured() } }); +} + +async function remove(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const id = ctx.params.id ?? ""; + if (!deleteGoal(account.id, id)) return sendJson(ctx.res, 404, { ok: false, error: "goal not found" }); + sendJson(ctx.res, 200, { ok: true }); +} + +async function advise(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const goal = findGoal(account.id, ctx.params.id ?? ""); + if (!goal) return sendJson(ctx.res, 404, { ok: false, error: "goal not found" }); + try { + const suggestions = await generateGoalSuggestions(await refreshGoal(goal)); + saveGoalSuggestions(account.id, goal.id, suggestions); + sendJson(ctx.res, 200, { ok: true, suggestions, generatedAt: new Date().toISOString(), aiEnabled: isAiConfigured() }); + } catch (error) { + sendJson(ctx.res, 502, { ok: false, error: (error as Error).message }); + } +} + +async function proposals(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const goal = findGoal(account.id, ctx.params.id ?? ""); + if (!goal) return sendJson(ctx.res, 404, { ok: false, error: "goal not found" }); + const index = Number(ctx.params.index); + const suggestion = Number.isInteger(index) ? goal.suggestions[index] : undefined; + if (!suggestion) return sendJson(ctx.res, 404, { ok: false, error: "suggestion not found" }); + const refresh = ctx.url.searchParams.get("refresh") === "1"; + if (!refresh && suggestion.proposals?.length && suggestion.proposalsVersion === SOCIAL_PROPOSALS_VERSION) { + return sendJson(ctx.res, 200, { ok: true, proposals: suggestion.proposals, generatedAt: suggestion.proposalsGeneratedAt, cached: true }); + } + try { + const generated = await generateGoalProposals(goal, suggestion); + if (!generated.length) return sendJson(ctx.res, 502, { ok: false, error: "AI returned no proposals" }); + const saved = saveGoalProposals(account.id, goal.id, index, generated, SOCIAL_PROPOSALS_VERSION); + sendJson(ctx.res, 200, { ok: true, proposals: generated, generatedAt: saved?.proposalsGeneratedAt ?? new Date().toISOString(), cached: false }); + } catch (error) { + if (error instanceof AiNotConfiguredError) return sendJson(ctx.res, 409, { ok: false, error: error.message, aiEnabled: false }); + sendJson(ctx.res, error instanceof AiRequestError ? 502 : 500, { ok: false, error: (error as Error).message }); + } +} + +export function registerGoalRoutes(router: AppRouter): void { + router.post("/api/goals/:id/suggestions/:index/proposals", proposals); + router.get("/api/goals", list); + router.post("/api/goals", create); + router.delete("/api/goals/:id", remove); + router.post("/api/goals/:id/advice", advise); +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index e27ebd4..56e4ec6 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,8 +1,10 @@ import type { AppRouter } from "../router"; import { registerAccountRoutes } from "./accounts"; +import { registerAiRoutes } from "./ai"; import { registerAuthRoutes } from "./auth"; import { registerDashboardRoutes } from "./dashboard"; import { registerMentionRoutes } from "./mentions"; +import { registerGoalRoutes } from "./goals"; import { registerNotificationRoutes } from "./notifications"; import { registerProjectRoutes } from "./projects"; import { registerRepositoryRoutes } from "./repository"; @@ -13,6 +15,8 @@ export function registerApiRoutes(router: AppRouter): void { registerDashboardRoutes(router); registerRepositoryRoutes(router); registerMentionRoutes(router); + registerGoalRoutes(router); registerProjectRoutes(router); registerNotificationRoutes(router); + registerAiRoutes(router); } diff --git a/src/server/spa.ts b/src/server/spa.ts index 26556ce..c1236e0 100644 --- a/src/server/spa.ts +++ b/src/server/spa.ts @@ -15,6 +15,8 @@ const APP_ROUTES = new Set([ "/ci", "/daily", "/board", + "/goals", + "/preferences", "/alert", ]); diff --git a/src/server/sqlite.ts b/src/server/sqlite.ts new file mode 100644 index 0000000..7404a48 --- /dev/null +++ b/src/server/sqlite.ts @@ -0,0 +1,38 @@ +import Database, { type Database as DatabaseType, type RunResult } from "better-sqlite3"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DATA_DIR } from "./config"; + +let database: DatabaseType | null = null; + +/** Shared SQLite helper for small, server-side persisted features. */ +export function getDatabase(path = `${DATA_DIR}/gitdeck.sqlite`): DatabaseType { + if (database) return database; + mkdirSync(dirname(path), { recursive: true }); + database = new Database(path); + database.pragma("journal_mode = WAL"); + database.pragma("foreign_keys = ON"); + return database; +} + +export function execute(sql: string): void { + getDatabase().exec(sql); +} + +export function run(sql: string, params: unknown[] = []): RunResult { + return getDatabase().prepare(sql).run(...params); +} + +export function get(sql: string, params: unknown[] = []): T | undefined { + return getDatabase().prepare(sql).get(...params) as T | undefined; +} + +export function all(sql: string, params: unknown[] = []): T[] { + return getDatabase().prepare(sql).all(...params) as T[]; +} + +/** Primarily useful for tests that need an isolated database. */ +export function closeDatabase(): void { + database?.close(); + database = null; +} diff --git a/src/styles.css b/src/styles.css index 6073ded..6454f54 100644 --- a/src/styles.css +++ b/src/styles.css @@ -9,3 +9,4 @@ @import "./styles/inbox.css"; @import "./styles/footer.css"; @import "./styles/preferences.css"; +@import "./styles/goals.css"; diff --git a/src/styles/goals.css b/src/styles/goals.css new file mode 100644 index 0000000..df4f810 --- /dev/null +++ b/src/styles/goals.css @@ -0,0 +1,198 @@ +.goals-view { display: grid; gap: 14px; } +.goal-create-card { display: grid; gap: 18px; padding: 18px; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 10px; } +.goal-create-intro { display: flex; align-items: center; gap: 12px; min-width: 0; } +.goal-create-icon { display: grid; place-items: center; width: 38px; height: 38px; flex: 0 0 auto; color: var(--accent-2); background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); border-radius: 10px; } +.goal-create-icon svg { width: 18px; height: 18px; } +.goal-create-card h2 { margin: 0 0 4px; font-size: 17px; line-height: 1.2; } +.goal-create-card p { max-width: 520px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.4; } +.goal-form { display: grid; grid-template-columns: minmax(240px, 2fr) minmax(130px, .9fr) minmax(130px, .9fr) minmax(160px, 1fr) auto; gap: 10px; align-items: end; min-width: 0; } +.goal-form > .btn { min-height: 36px; padding-inline: 14px; white-space: nowrap; } +@media (min-width: 1280px) { + .goal-create-card { grid-template-columns: minmax(260px, .75fr) minmax(720px, 2.5fr); align-items: center; padding: 16px 18px; } +} +.goal-form label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.goal-form input, .goal-form select { width: 100%; min-height: 34px; padding: 6px 9px; color: var(--text); background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; } +.goal-form input:focus, .goal-form select:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); } +.repository-picker { position: relative; min-width: 0; text-transform: none; letter-spacing: normal; font-weight: 400; } +.repository-picker-input { display: flex; align-items: center; min-height: 36px; padding: 0 9px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; transition: border-color .12s, box-shadow .12s; } +.repository-picker-input.open { border-color: var(--accent); box-shadow: var(--ring); } +.repository-picker-input > svg { width: 14px; height: 14px; flex: 0 0 auto; fill: none; stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; } +.goal-form .repository-picker-input input { min-width: 0; min-height: 34px; padding: 6px 8px; background: transparent; border: 0; box-shadow: none; } +.goal-form .repository-picker-input input:focus { border: 0; box-shadow: none; } +.repository-picker-chevron { color: var(--muted); font-size: 15px; } +.repository-picker-menu { position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; width: max(100%, 430px); max-width: min(90vw, 560px); max-height: 390px; overflow-y: auto; padding: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 14px 40px rgba(0,0,0,.35); } +.repository-picker-summary { padding: 6px 8px 8px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } +.repository-picker-menu button { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 9px; align-items: center; width: 100%; padding: 8px; color: var(--text); text-align: left; background: transparent; border: 0; border-radius: 7px; cursor: pointer; } +.repository-picker-menu button:hover, .repository-picker-menu button.active { background: var(--hover-surface); } +.repository-picker-menu button[aria-selected="true"] { box-shadow: inset 2px 0 var(--accent); } +.repository-picker-avatar { display: grid; place-items: center; width: 28px; height: 28px; color: var(--accent-2); background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 7px; } +.repository-picker-avatar svg { width: 15px; height: 15px; } +.repository-picker-copy { display: grid; min-width: 0; } +.repository-picker-copy strong { overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.repository-picker-copy small { overflow: hidden; margin-top: 2px; color: var(--muted); font-size: 10.5px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; } +.repository-picker-stats { display: grid; justify-items: end; color: var(--muted); font-size: 10.5px; white-space: nowrap; } +.repository-picker-stats small { margin-top: 2px; color: var(--muted-2); } +.goal-repository-list, .goals-loading-state { display: grid; gap: 16px; } +.goal-skeleton-card { pointer-events: none; } +.goal-skeleton { + display: block; + border-radius: 7px; + background: linear-gradient(100deg, var(--panel-3) 20%, color-mix(in srgb, var(--muted) 14%, var(--panel-2)) 38%, var(--panel-3) 56%); + background-size: 220% 100%; + animation: goalSkeletonShimmer 1.35s ease-in-out infinite; +} +.goal-skeleton-avatar { width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; } +.goal-skeleton-kicker { width: 92px; height: 7px; margin-bottom: 7px; } +.goal-skeleton-title { width: clamp(150px, 24vw, 280px); height: 17px; margin-bottom: 6px; } +.goal-skeleton-description { width: clamp(190px, 38vw, 470px); max-width: 100%; height: 9px; } +.goal-skeleton-score { width: 58px; height: 38px; } +.goal-skeleton-track { min-height: 115px; } +.goal-skeleton-metric { width: 68px; height: 9px; } +.goal-skeleton-orbit { width: 66px; height: 66px; border-radius: 50%; } +.goal-skeleton-track-copy { display: grid; gap: 9px; min-width: 0; } +.goal-skeleton-value { width: 105px; height: 19px; } +.goal-skeleton-progress { width: 100%; height: 6px; border-radius: 999px; } +.goal-skeleton-meta { width: 75%; height: 8px; } +.goal-skeleton-studio { display: grid; gap: 13px; } +.goal-skeleton-studio-title { width: 150px; height: 14px; } +.goal-skeleton-plan { min-height: 82px; } +@keyframes goalSkeletonShimmer { to { background-position-x: -220%; } } +@media (prefers-reduced-motion: reduce) { .goal-skeleton { animation: none; } } +.goal-repository-card { + --goal-tone: var(--accent); + position: relative; overflow: hidden; + background: linear-gradient(145deg, color-mix(in srgb, var(--panel) 96%, var(--accent) 4%), var(--panel)); + border: 1px solid color-mix(in srgb, var(--accent-2) 25%, var(--border-soft)); border-radius: 16px; + box-shadow: 0 18px 55px rgba(0,0,0,.18), inset 0 1px rgba(255,255,255,.035); +} +.goal-repository-card::before { content: ""; position: absolute; pointer-events: none; width: 440px; height: 220px; top: -150px; right: -80px; border-radius: 50%; background: color-mix(in srgb, var(--accent-2) 18%, transparent); filter: blur(55px); } +.goal-repository-card::after { content: ""; position: absolute; pointer-events: none; width: 280px; height: 180px; top: -130px; left: 12%; border-radius: 50%; background: color-mix(in srgb, var(--accent) 12%, transparent); filter: blur(48px); } +.goal-repository-hero { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 22px; border-bottom: 1px solid var(--border-soft); } +.goal-repository-identity { display: flex; align-items: center; gap: 13px; min-width: 0; } +.goal-repository-identity .avatar { flex: 0 0 auto; border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border)); box-shadow: 0 0 0 4px var(--accent-faint), 0 0 28px color-mix(in srgb, var(--accent) 18%, transparent); } +.goal-repository-identity > div { min-width: 0; } +.goal-repository-kicker { display: flex; align-items: center; gap: 6px; color: var(--accent); font-size: 9px; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; } +.goal-repository-kicker i { width: 6px; height: 6px; background: var(--accent); border-radius: 50%; box-shadow: 0 0 10px var(--accent); animation: goalPulse 2s ease-in-out infinite; } +@keyframes goalPulse { 50% { opacity: .45; transform: scale(.75); } } +.goal-repository-identity h2 { overflow: hidden; margin: 3px 0 2px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px; text-overflow: ellipsis; white-space: nowrap; } +.goal-repository-identity p { overflow: hidden; max-width: 700px; margin: 0; color: var(--muted); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } +.goal-repository-score { display: grid; min-width: 82px; justify-items: end; } +.goal-repository-score strong { color: var(--text); font-size: 24px; line-height: 1; } +.goal-repository-score strong span { color: var(--muted-2); font-size: 14px; } +.goal-repository-score small { margin-top: 5px; color: var(--muted); font-size: 9px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; } +.goal-track-grid { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(285px, 100%), 1fr)); gap: 10px; padding: 14px; } +.goal-track { padding: 13px; background: color-mix(in srgb, var(--panel-2) 72%, transparent); border: 1px solid var(--border-soft); border-radius: 12px; transition: transform .15s, border-color .15s; } +.goal-track:hover { transform: translateY(-1px); border-color: var(--accent-border); } +.goal-track.complete { --goal-tone: var(--success); } +.goal-track.overdue { --goal-tone: var(--danger); } +.goal-track > header { display: flex; align-items: center; justify-content: space-between; min-height: 24px; } +.goal-metric { color: var(--accent-2); font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .1em; } +.goal-track-main { display: grid; grid-template-columns: 68px minmax(0, 1fr); gap: 14px; align-items: center; margin-top: 8px; } +.goal-progress-orbit { display: grid; place-items: center; width: 66px; height: 66px; padding: 5px; border-radius: 50%; box-shadow: 0 0 20px color-mix(in srgb, var(--goal-tone) 15%, transparent); } +.goal-progress-orbit > div { display: flex; align-items: baseline; justify-content: center; width: 100%; height: 100%; background: var(--panel); border-radius: 50%; } +.goal-progress-orbit strong { align-self: center; font-size: 18px; } +.goal-progress-orbit span { align-self: center; color: var(--muted); font-size: 10px; } +.goal-values { display: flex; align-items: baseline; gap: 5px; } +.goal-values strong { font-size: 21px; } +.goal-values span { color: var(--muted); font-size: 11px; } +.goal-progress { height: 6px; margin: 8px 0 7px; overflow: hidden; background: var(--panel-3); border-radius: 999px; } +.goal-progress span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--goal-tone), var(--accent-2)); box-shadow: 0 0 12px var(--goal-tone); transition: width .25s ease; } +.goal-meta { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 10px; } +.goal-growth-studio { position: relative; z-index: 1; padding: 17px 18px 20px; border-top: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg) 28%, transparent); } +.goal-studio-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 13px; } +.goal-studio-heading span { color: var(--accent); font-size: 9px; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; } +.goal-studio-heading h3 { margin: 2px 0 0; font-size: 15px; } +.goal-studio-heading p { max-width: 520px; margin: 0; color: var(--muted); font-size: 11px; text-align: right; } +.goal-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 10px; } +.goal-plan { overflow: hidden; background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--border-soft); border-radius: 12px; } +.goal-plan-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; background: linear-gradient(90deg, var(--accent-faint), transparent); border-bottom: 1px solid var(--border-soft); } +.goal-plan-head > div { display: grid; gap: 1px; } +.goal-plan-head span { color: var(--accent-2); font-size: 8.5px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; } +.goal-plan-head strong { font-size: 12px; } +.goal-plan .goal-ai-note { padding-inline: 12px; } +.goal-suggestion-list { padding: 3px 12px 12px; } +.goal-advice { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-soft); } +.goal-advice-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 10px; } +.goal-advice-title strong { font-size: 13px; } +.goal-ai-note { color: var(--muted); font-size: 11px; } +.goal-suggestion { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "cat title btn" "cat body body"; gap: 4px 8px; align-items: start; margin-top: 9px; } +.goal-suggestion > span { grid-area: cat; } +.goal-suggestion > strong { grid-area: title; } +.goal-suggestion > p { grid-area: body; } +.goal-suggestion-proposals { + grid-area: btn; display: inline-flex; align-items: center; gap: 5px; + padding: 3px 8px; border-radius: 999px; + border: 1px solid var(--border-soft); background: var(--panel-2); + color: var(--muted); font-size: 10.5px; font-weight: 700; white-space: nowrap; cursor: pointer; + transition: color .12s, border-color .12s, background .12s; +} +.goal-suggestion-proposals:hover { color: var(--accent); border-color: var(--accent-border); background: var(--accent-faint); } +.goal-suggestion-proposals.has-proposals { color: var(--accent); border-color: var(--accent-border); } +.goal-suggestion-proposals svg { flex: 0 0 auto; } +@media (max-width: 520px) { .goal-suggestion-proposals span { display: none; } } +.goal-suggestion > span { padding: 2px 6px; align-self: start; color: var(--accent-2); background: var(--panel-2); border-radius: 999px; font-size: 9px; text-transform: uppercase; } +.goal-suggestion > strong { font-size: 12px; line-height: 1.5; } +.goal-suggestion p { margin: 0; color: var(--muted); font-size: 11.5px; line-height: 1.45; white-space: pre-wrap; } +@media (max-width: 900px) { + .goal-form { grid-template-columns: 1fr 1fr; } + .goal-studio-heading { display: grid; } + .goal-studio-heading p { text-align: left; } +} +@media (max-width: 560px) { + .goal-form { grid-template-columns: 1fr; } + .goal-repository-hero { align-items: flex-start; padding: 16px; } + .goal-repository-identity p { white-space: normal; } + .goal-repository-score small { display: none; } + .goal-track-grid { padding: 10px; } + .goal-growth-studio { padding: 15px 10px; } +} + +/* Proposals modal */ +.modal.goal-proposals-modal { width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(90vh, 960px); } +.goal-proposals-category { color: var(--accent-2); text-transform: uppercase; } +.goal-proposals-body { display: grid; gap: 14px; padding: 18px; } +.goal-proposals-intro { margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.5; } +.goal-proposals-action { margin: 0; padding: 10px 14px; border-left: 3px solid var(--accent); border-radius: 0 8px 8px 0; background: var(--panel-2); color: var(--text); font-size: 12.5px; line-height: 1.5; } +.goal-proposals-loading { display: flex; align-items: center; gap: 10px; padding: 26px 0; color: var(--muted); font-size: 13px; } +.goal-proposals-spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: goalSpin .8s linear infinite; } +@keyframes goalSpin { to { transform: rotate(360deg); } } +.goal-proposals-note { display: grid; justify-items: start; gap: 10px; padding: 16px; border: 1px dashed var(--border); border-radius: 10px; color: var(--muted); font-size: 12.5px; } +.goal-proposals-note p { margin: 0; } +.goal-proposal-list { display: grid; gap: 12px; transition: opacity .15s; } +.goal-proposal-list.refreshing { opacity: .5; pointer-events: none; } +.goal-proposal { border: 1px solid var(--border-soft); border-radius: 10px; background: var(--panel); overflow: hidden; } +.goal-proposal-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--border-soft); background: var(--panel-2); } +.goal-proposal-format { padding: 2px 8px; border-radius: 999px; border: 1px solid var(--accent-border); background: var(--accent-faint); color: var(--accent); font-size: 9.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; white-space: nowrap; } +.goal-proposal-format.format-issue, .goal-proposal-format.format-discussion { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border)); background: color-mix(in srgb, var(--accent-2) 12%, var(--panel-2)); } +.goal-proposal-format.format-email, .goal-proposal-format.format-message { color: #d29922; border-color: rgba(210, 153, 34, .5); background: rgba(210, 153, 34, .1); } +.goal-proposal-format.format-x-thread { color: var(--text); border-color: color-mix(in srgb, var(--text) 35%, var(--border)); background: color-mix(in srgb, var(--text) 7%, var(--panel-2)); } +.goal-proposal-copy-block { display: grid; min-width: 0; } +.goal-proposal-copy-block strong { font-size: 13px; line-height: 1.3; } +.goal-proposal-copy-block small { margin-top: 2px; color: var(--muted); font-size: 11.5px; line-height: 1.4; } +.goal-proposal-copy { min-height: 30px; padding: 4px 10px; font-size: 12px; } +.goal-proposal-copy.copied { color: #3fb950; border-color: rgba(46, 160, 67, .5); } +.goal-proposal-content { padding: 12px 16px 14px; font-size: 13px; line-height: 1.55; } +.goal-proposal-content > :first-child { margin-top: 0; } +.goal-proposal-content > :last-child { margin-bottom: 0; } +.goal-proposal-content .task-list-item { flex-wrap: wrap; } +.goal-proposal-content .task-list-item > ul, .goal-proposal-content .task-list-item > ol { flex-basis: 100%; margin-left: 22px; } +.goal-x-thread { display: grid; padding: 15px 18px 18px; } +.goal-x-post { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 10px; } +.goal-x-post-rail { display: grid; grid-template-rows: 30px 1fr; justify-items: center; } +.goal-x-avatar { display: grid; place-items: center; width: 30px; height: 30px; color: var(--panel); background: var(--text); border-radius: 50%; font-size: 11px; font-weight: 900; } +.goal-x-post-rail i { width: 2px; min-height: 18px; margin-block: 4px; background: var(--border); } +.goal-x-post-body { min-width: 0; padding-bottom: 15px; } +.goal-x-post:last-child .goal-x-post-body { padding-bottom: 0; } +.goal-x-post-body > header { display: flex; align-items: center; gap: 7px; min-height: 30px; } +.goal-x-post-body > header > strong { overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.goal-x-post-body > header > span { color: var(--muted); font-size: 10.5px; } +.goal-x-post-body > header .goal-proposal-copy { min-height: 25px; margin-left: auto; padding: 2px 7px; font-size: 10.5px; } +.goal-x-post-content { padding: 3px 0 2px; font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } +.goal-x-post-body > small { display: block; color: var(--muted-2); font-size: 9.5px; text-align: right; } +.goal-x-post-body > small.over-limit { color: #f85149; font-weight: 700; } +@media (max-width: 560px) { + .goal-proposal-head { grid-template-columns: auto minmax(0, 1fr); } + .goal-proposal-head > .goal-proposal-copy { grid-column: 1 / -1; justify-self: end; } + .goal-x-thread { padding-inline: 12px; } + .goal-x-post-body > header .goal-proposal-copy { padding-inline: 5px; } +} diff --git a/src/styles/preferences.css b/src/styles/preferences.css index 4ea1f97..99e8f81 100644 --- a/src/styles/preferences.css +++ b/src/styles/preferences.css @@ -25,3 +25,165 @@ :root[data-text-size="large"] :where(.btn, .tab, input, select, .toolbar label, .count-chip, .data-row-title, .repo-desc, .rc-stats, .empty, .pagination, .modal-empty, .welcome-list, .digest-card, .insight-card, .ci-table) { font-size: calc(1em + 1px); } + + /* Link from the quick popover to the full page */ + .preferences-page-link { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "title icon" "meta icon"; + align-items: center; + column-gap: 10px; + width: 100%; + margin-top: 8px; + padding: 9px 10px; + border: 1px solid var(--border-soft); + border-radius: 7px; + background: var(--panel-2); + color: var(--text); + font-size: 12.5px; + font-weight: 700; + text-align: left; + cursor: pointer; + transition: border-color .12s, background .12s; + } + .preferences-page-link:hover { border-color: var(--accent-border); background: var(--accent-faint); } + .preferences-page-link > span:first-child { grid-area: title; } + .preferences-page-link-meta { grid-area: meta; color: var(--muted); font-size: 11px; font-weight: 500; } + .preferences-page-link svg { grid-area: icon; color: var(--muted); } + .preferences-page-link:hover svg { color: var(--accent); } + + /* Dedicated /preferences page */ + body.route-preferences .sidebar { display: none; } + body.route-preferences .layout { grid-template-columns: minmax(0, 1fr); } + body.route-preferences .filters-toggle { display: none !important; } + + .preferences-page { display: grid; gap: 22px; width: min(1120px, 100%); margin: 6px auto 0; } + .preferences-page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--border-soft); } + .preferences-page-head h2 { margin: 0 0 6px; font-size: 24px; line-height: 1.15; letter-spacing: -0.01em; } + .preferences-page-head p { margin: 0; color: var(--muted); font-size: 13px; } + .preferences-body { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 28px; align-items: start; } + .preferences-nav { position: sticky; top: 76px; display: grid; gap: 2px; } + .preferences-nav-title { margin: 0 0 6px; padding: 0 10px; color: var(--muted); font-size: 10.5px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } + .preferences-nav a { display: flex; align-items: center; gap: 9px; padding: 8px 10px; border-radius: 7px; color: var(--muted); font-size: 12.5px; font-weight: 600; text-decoration: none; } + .preferences-nav a svg { width: 15px; height: 15px; flex: 0 0 auto; } + .preferences-nav a:hover { color: var(--text); background: var(--hover-surface); } + .preferences-content { display: grid; gap: 18px; min-width: 0; } + + .preferences-card { padding: 20px 22px 22px; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 12px; box-shadow: 0 1px 0 rgba(255,255,255,.035); scroll-margin-top: 80px; } + .preferences-card-head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 18px; } + .preferences-card-head h3 { margin: 0 0 3px; font-size: 16px; line-height: 1.25; } + .preferences-card-head p { margin: 0; max-width: 620px; color: var(--muted); font-size: 12.5px; line-height: 1.45; } + .preferences-card-icon { display: grid; place-items: center; width: 38px; height: 38px; flex: 0 0 auto; color: var(--text); background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 10px; } + .preferences-card-icon.accent { color: var(--accent); background: var(--accent-faint); border-color: var(--accent-border); } + .preferences-card-icon svg { width: 18px; height: 18px; } + .preferences-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 6px 22px; } + .preferences-card .preferences-field { padding: 6px 0; } + .preferences-switch { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-top: 12px; padding: 12px 14px; background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 9px; } + .preferences-switch strong { display: block; font-size: 12.5px; font-weight: 700; } + .preferences-switch small { display: block; margin-top: 2px; color: var(--muted); font-size: 11.5px; line-height: 1.4; } + + /* AI integration editor */ + .ai-settings { display: grid; gap: 18px; } + .ai-settings-loading { color: var(--muted); font-size: 12.5px; } + .ai-hero { + display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "status copy" "sources sources"; + align-items: center; gap: 10px 14px; + padding: 14px 16px; + border: 1px solid var(--border-soft); border-radius: 10px; + background: linear-gradient(135deg, color-mix(in srgb, var(--panel-2) 90%, transparent), var(--panel)); + } + .ai-hero.on { border-color: rgba(46, 160, 67, .35); background: linear-gradient(135deg, rgba(46, 160, 67, .10), var(--panel) 65%); } + .ai-hero .ai-status { grid-area: status; } + .ai-hero-copy { grid-area: copy; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; min-width: 0; font-size: 14px; } + .ai-hero-copy code { padding: 2px 7px; border-radius: 6px; background: color-mix(in srgb, var(--panel-3) 80%, transparent); color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } + .ai-hero-sources { grid-area: sources; display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--muted); font-size: 11.5px; font-weight: 600; } + .ai-hero-sources > span { display: inline-flex; align-items: center; gap: 6px; } + .ai-status { + display: inline-flex; align-items: center; gap: 7px; + padding: 5px 11px; border-radius: 999px; + border: 1px solid var(--border); background: var(--panel-2); + color: var(--muted); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; + } + .ai-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); } + .ai-status.on { color: #3fb950; border-color: rgba(46, 160, 67, .55); background: rgba(46, 160, 67, .12); } + .ai-status.on::before { background: #3fb950; box-shadow: 0 0 0 3px rgba(63, 185, 80, .2); } + + .ai-block { display: grid; gap: 10px; } + .ai-block-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } + .ai-block-title { color: var(--text); font-size: 12.5px; font-weight: 700; } + .ai-block-hint { color: var(--muted); font-size: 11.5px; } + .ai-provider-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(190px, 100%), 1fr)); gap: 8px; } + .ai-provider-card { + display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "glyph copy" "tags tags"; + align-items: center; gap: 8px 10px; + padding: 10px 11px; + border: 1px solid var(--border-soft); border-radius: 10px; + background: var(--panel-2); color: var(--text); + text-align: left; cursor: pointer; + transition: border-color .12s, box-shadow .12s, background .12s; + } + .ai-provider-card:hover { border-color: var(--button-hover-border); background: var(--hover-surface); } + .ai-provider-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--ring); background: var(--accent-faint); } + .ai-provider-glyph { grid-area: glyph; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; color: #fff; font-size: 14px; font-weight: 800; letter-spacing: 0; } + .ai-provider-glyph-openai { background: linear-gradient(135deg, #10a37f, #0b7a5f); } + .ai-provider-glyph-anthropic { background: linear-gradient(135deg, #d4a27f, #b07a54); } + .ai-provider-glyph-gemini { background: linear-gradient(135deg, #4c8bf5, #9b6cf6); } + .ai-provider-glyph-openrouter { background: linear-gradient(135deg, #7c6cf0, #4b3fb8); } + .ai-provider-glyph-custom { background: linear-gradient(135deg, #5b6b82, #3b475a); font-size: 16px; } + .ai-provider-copy { grid-area: copy; display: grid; min-width: 0; } + .ai-provider-copy strong { overflow: hidden; font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } + .ai-provider-copy small { overflow: hidden; margin-top: 1px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } + .ai-provider-tags { grid-area: tags; display: flex; flex-wrap: wrap; gap: 4px; min-height: 16px; } + .ai-provider-tags:empty { display: none; } + .ai-provider-tag { padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border-soft); color: var(--muted); font-size: 9.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; } + .ai-provider-tag.active { color: #3fb950; border-color: rgba(46, 160, 67, .5); } + .ai-provider-tag.key-env { color: #d29922; border-color: rgba(210, 153, 34, .5); } + .ai-provider-tag.key-stored { color: var(--accent); border-color: var(--accent-border); } + + .ai-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; } + .ai-field { display: grid; gap: 6px; min-width: 0; font-size: 12.5px; } + .ai-field-wide { grid-column: 1 / -1; } + .ai-field-label { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 11.5px; font-weight: 700; } + .ai-field input { + width: 100%; min-height: 36px; padding: 7px 10px; + color: var(--text); background: var(--panel-2); + border: 1px solid var(--border); border-radius: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; + transition: border-color .12s, box-shadow .12s; + } + .ai-field input::placeholder { color: var(--muted-2); } + .ai-field input:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); } + .ai-field small { color: var(--muted); font-size: 11px; line-height: 1.4; } + .ai-source { + padding: 2px 7px; border-radius: 999px; + border: 1px solid var(--border-soft); background: var(--panel-2); + color: var(--muted); font-size: 9.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; white-space: nowrap; + } + .ai-source-database { color: var(--accent); border-color: var(--accent-border); background: var(--accent-soft); } + .ai-source-env { color: #d29922; border-color: rgba(210, 153, 34, .5); background: rgba(210, 153, 34, .1); } + + .ai-settings-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + .ai-settings-actions .spacer { flex: 1; } + .ai-settings-actions .btn { min-height: 34px; padding-inline: 14px; } + .ai-link-danger { padding: 6px 8px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer; } + .ai-link-danger:hover { color: var(--danger, #f85149); background: rgba(248, 81, 73, .08); } + .ai-link-danger:disabled { opacity: .5; cursor: default; } + .ai-settings-notice { padding: 6px 10px; border-radius: 7px; font-size: 12px; font-weight: 600; border: 1px solid var(--border-soft); background: var(--panel-2); } + .ai-settings-notice.ok { color: #3fb950; border-color: rgba(46, 160, 67, .4); background: rgba(46, 160, 67, .08); } + .ai-settings-notice.error { color: #f85149; border-color: rgba(248, 81, 73, .4); background: rgba(248, 81, 73, .08); } + .ai-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 14px; border-top: 1px dashed var(--border-soft); color: var(--muted); font-size: 11.5px; } + .ai-legend-title { margin-right: 4px; font-weight: 700; } + .ai-legend-arrow { color: var(--muted-2); } + .ai-legend-text { margin-left: 6px; } + + @media (max-width: 960px) { + .preferences-body { grid-template-columns: minmax(0, 1fr); } + .preferences-nav { position: static; display: flex; flex-wrap: wrap; gap: 4px; } + .preferences-nav-title { display: none; } + } + @media (max-width: 720px) { + .preferences-page-head { flex-direction: column; align-items: flex-start; } + .preferences-card { padding: 16px; } + .ai-fields { grid-template-columns: minmax(0, 1fr); } + .ai-hero { grid-template-columns: minmax(0, 1fr); grid-template-areas: "status" "copy" "sources"; } + } diff --git a/src/types/ai.ts b/src/types/ai.ts new file mode 100644 index 0000000..7a7fd86 --- /dev/null +++ b/src/types/ai.ts @@ -0,0 +1,49 @@ +export const AI_PROVIDER_IDS = ["openai", "anthropic", "gemini", "openrouter", "custom"] as const; +export type AiProviderId = (typeof AI_PROVIDER_IDS)[number]; + +/** Where an effective AI setting value comes from. */ +export type AiSettingSource = "database" | "env" | "default" | "none"; + +export interface AiProviderInfo { + id: AiProviderId; + label: string; + /** Environment variable read for this provider's API key. */ + envKeyName: string; + defaultModel: string | null; + defaultBaseUrl: string; + requiresApiKey: boolean; + /** Whether the base URL is meaningful for the user (custom endpoints). */ + supportsBaseUrl: boolean; + hasEnvKey: boolean; + hasStoredKey: boolean; + storedModel: string | null; + storedBaseUrl: string | null; +} + +export interface AiSettingsSummary { + /** True when the active provider has everything it needs to answer requests. */ + enabled: boolean; + provider: { value: AiProviderId; source: AiSettingSource }; + apiKey: { configured: boolean; masked: string | null; source: AiSettingSource }; + model: { value: string | null; source: AiSettingSource }; + baseUrl: { value: string; source: AiSettingSource }; + providers: AiProviderInfo[]; +} + +export interface AiSettingsUpdate { + provider?: AiProviderId; + /** Omit to keep the stored key, empty string to remove the override. */ + apiKey?: string; + /** Empty string removes the override. */ + model?: string; + /** Empty string removes the override. */ + baseUrl?: string; +} + +export interface AiConnectionTest { + ok: true; + provider: AiProviderId; + model: string; + latencyMs: number; + reply: string; +} diff --git a/src/types/github.ts b/src/types/github.ts index c601e3b..6cb20e4 100644 --- a/src/types/github.ts +++ b/src/types/github.ts @@ -388,6 +388,7 @@ export interface DailyRepoDigest { momentum: string[]; risks: string[]; ai?: { + provider?: string; model: string; headline: string; briefing: string[]; @@ -415,6 +416,7 @@ export interface DailyDigestEntry { risks: string[]; repos: DailyRepoDigest[]; ai?: { + provider?: string; model: string; headline: string; briefing: string[]; diff --git a/src/types/goals.ts b/src/types/goals.ts new file mode 100644 index 0000000..07cefb6 --- /dev/null +++ b/src/types/goals.ts @@ -0,0 +1,61 @@ +/** Add display metadata here; the metric type and creation UI update automatically. */ +export const GOAL_METRIC_DEFINITIONS = [ + { id: "stars", label: "Stars" }, + { id: "forks", label: "Forks" }, + { id: "closed_prs", label: "Closed PRs" }, + { id: "downloads", label: "Release downloads" }, +] as const; + +export type GoalMetric = typeof GOAL_METRIC_DEFINITIONS[number]["id"]; +export const GOAL_METRICS: readonly GoalMetric[] = GOAL_METRIC_DEFINITIONS.map((metric) => metric.id); + +export const GOAL_PROPOSAL_FORMATS = ["x-thread", "linkedin-post", "mastodon-post", "post", "issue", "discussion", "email", "checklist", "message", "doc"] as const; +export type GoalProposalFormat = (typeof GOAL_PROPOSAL_FORMATS)[number]; + +/** A ready-to-use deliverable that carries out one recommended action. */ +export interface GoalProposal { + title: string; + format: GoalProposalFormat; + summary: string; + /** Markdown text the user can copy and publish or adapt. */ + content: string; + /** Complete, ordered X posts. Present when format is `x-thread`. */ + threadPosts?: string[]; +} + +export interface GoalSuggestion { + title: string; + action: string; + category: "product" | "community" | "engineering" | "marketing"; + proposals?: GoalProposal[]; + proposalsGeneratedAt?: string | null; + /** Generation strategy version, used to invalidate obsolete cached drafts. */ + proposalsVersion?: number; +} + +export interface GoalProposalsData { + ok: true; + proposals: GoalProposal[]; + generatedAt: string; + cached: boolean; +} + +export interface RepositoryGoal { + id: string; + accountId: string; + repository: string; + metric: GoalMetric; + targetValue: number; + currentValue: number; + deadline: string; + createdAt: string; + updatedAt: string; + suggestions: GoalSuggestion[]; + suggestionsGeneratedAt: string | null; + aiEnabled: boolean; +} + +export interface GoalsData { + ok: true; + goals: RepositoryGoal[]; +} diff --git a/src/utils/dataRequirements.ts b/src/utils/dataRequirements.ts index 1969329..fd5c4ef 100644 --- a/src/utils/dataRequirements.ts +++ b/src/utils/dataRequirements.ts @@ -1,4 +1,4 @@ -export type DashboardTab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests"; +export type DashboardTab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "goals"; export type DashboardResource = "repos" | "issues" | "prs"; @@ -21,6 +21,7 @@ export function dataRequirementsForTab( resources.add("prs"); break; case "repos": + case "goals": resources.add("repos"); break; case "insights": diff --git a/src/utils/digests.ts b/src/utils/digests.ts index 0a0c15e..8c2fe07 100644 --- a/src/utils/digests.ts +++ b/src/utils/digests.ts @@ -29,6 +29,7 @@ export interface DailyDigestRecord { totalForks: number; repos: DailyRepoRecord[]; ai?: { + provider?: string; model: string; headline: string; briefing: string[]; diff --git a/src/utils/goals.ts b/src/utils/goals.ts new file mode 100644 index 0000000..686c912 --- /dev/null +++ b/src/utils/goals.ts @@ -0,0 +1,49 @@ +import type { GoalProposal, RepositoryGoal } from "../types/goals"; + +export interface RepositoryGoalGroup { + repository: string; + goals: RepositoryGoal[]; +} + +/** Groups goals without changing repository or goal insertion order. */ +export function groupGoalsByRepository(goals: RepositoryGoal[]): RepositoryGoalGroup[] { + const groups = new Map(); + for (const goal of goals) { + const current = groups.get(goal.repository); + if (current) current.push(goal); + else groups.set(goal.repository, [goal]); + } + return [...groups].map(([repository, repositoryGoals]) => ({ repository, goals: repositoryGoals })); +} + +export interface GoalProgress { + percentage: number; + remaining: number; + daysRemaining: number; + completed: boolean; + overdue: boolean; +} + +/** Produces a copy-ready thread while keeping each X post visibly separated. */ +export function formatXThreadForCopy(proposal: Pick): string { + const posts = proposal.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? []; + return posts.length ? posts.join("\n\n---\n\n") : proposal.content.trim(); +} + +export function calculateGoalProgress( + goal: Pick, + now = new Date(), +): GoalProgress { + const target = Math.max(1, goal.targetValue); + const percentage = Math.min(100, Math.max(0, Math.round((goal.currentValue / target) * 100))); + const completed = goal.currentValue >= goal.targetValue; + const deadline = new Date(`${goal.deadline}T23:59:59.999Z`).getTime(); + const daysRemaining = Math.max(0, Math.ceil((deadline - now.getTime()) / 86_400_000)); + return { + percentage, + remaining: Math.max(0, goal.targetValue - goal.currentValue), + daysRemaining, + completed, + overdue: !completed && deadline < now.getTime(), + }; +} diff --git a/src/utils/socialProposals.ts b/src/utils/socialProposals.ts new file mode 100644 index 0000000..dcd19db --- /dev/null +++ b/src/utils/socialProposals.ts @@ -0,0 +1,80 @@ +import type { GoalProposal, GoalProposalFormat } from "../types/goals"; + +export const SOCIAL_PROPOSAL_FORMATS = ["x-thread", "linkedin-post", "mastodon-post"] as const satisfies readonly GoalProposalFormat[]; + +const SOCIAL_LIMITS: Partial> = { + "x-thread": 280, + "linkedin-post": 3_000, + "mastodon-post": 500, +}; + +/** Counts Unicode code points rather than UTF-16 units, which avoids double-counting most emoji. */ +export function socialCharacterCount(text: string): number { + return Array.from(text).length; +} + +function hashtagCount(text: string): number { + return [...text.matchAll(/(?:^|\s)#[\p{L}\p{N}_]+/gu)].length; +} + +export function socialProposalIssue(proposal: GoalProposal): string | null { + if (!proposal.title.trim()) return "missing title"; + if (!proposal.summary.trim()) return "missing audience and angle summary"; + if (!proposal.content.trim()) return "missing content"; + if (!SOCIAL_PROPOSAL_FORMATS.includes(proposal.format as (typeof SOCIAL_PROPOSAL_FORMATS)[number])) { + return `unsupported social format: ${proposal.format}`; + } + + if (proposal.format === "x-thread") { + const posts = proposal.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? []; + if (posts.length < 5 || posts.length > 7) return "X thread must contain 5–7 posts"; + if (posts.some((post) => socialCharacterCount(post) > SOCIAL_LIMITS["x-thread"]!)) return "X post exceeds 280 characters"; + if (hashtagCount(posts.join("\n")) > 2) return "X thread contains more than 2 hashtags"; + return null; + } + + const limit = SOCIAL_LIMITS[proposal.format]; + if (limit && socialCharacterCount(proposal.content) > limit) return `${proposal.format} exceeds ${limit} characters`; + const hashtagLimit = proposal.format === "linkedin-post" ? 3 : 2; + return hashtagCount(proposal.content) > hashtagLimit ? `${proposal.format} contains too many hashtags` : null; +} + +/** + * Sanitizes model output and rejects incomplete, duplicate, or platform-invalid + * social drafts instead of showing content that cannot actually be published. + */ +export function normalizeSocialProposals(entries: unknown): GoalProposal[] { + if (!Array.isArray(entries)) return []; + const proposals: GoalProposal[] = []; + const seenFormats = new Set(); + const seenContent = new Set(); + + for (const raw of entries) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const format = String(entry.format ?? "") as GoalProposalFormat; + const posts = format === "x-thread" && Array.isArray(entry.threadPosts) + ? entry.threadPosts.map((post) => String(post).trim()).filter(Boolean).slice(0, 7) + : undefined; + const content = format === "x-thread" && posts?.length + ? posts.join("\n\n---\n\n") + : String(entry.content ?? "").trim(); + const proposal: GoalProposal = { + title: String(entry.title ?? "").trim(), + format, + summary: String(entry.summary ?? "").trim(), + content, + threadPosts: posts, + }; + const fingerprint = content.toLocaleLowerCase(); + if (seenFormats.has(format) || seenContent.has(fingerprint) || socialProposalIssue(proposal)) continue; + seenFormats.add(format); + seenContent.add(fingerprint); + proposals.push(proposal); + } + return proposals; +} + +export function hasCompleteSocialSet(proposals: GoalProposal[]): boolean { + return SOCIAL_PROPOSAL_FORMATS.every((format) => proposals.some((proposal) => proposal.format === format)); +} diff --git a/tests/server/aiClient.test.ts b/tests/server/aiClient.test.ts new file mode 100644 index 0000000..dd67ea1 --- /dev/null +++ b/tests/server/aiClient.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AiNotConfiguredError, generateStructured, parseJsonAnswer } from "../../src/server/ai/client"; +import { AI_PROVIDERS } from "../../src/server/ai/providers"; +import type { ResolvedAiConfig } from "../../src/server/ai/settings"; + +function config(id: keyof typeof AI_PROVIDERS, overrides: Partial = {}): ResolvedAiConfig { + const provider = AI_PROVIDERS[id]; + return { + provider, + providerSource: "env", + apiKey: "test-key", + apiKeySource: "env", + model: provider.defaultModel ?? "local-model", + modelSource: "default", + baseUrl: provider.defaultBaseUrl, + baseUrlSource: "default", + ...overrides, + }; +} + +const request = { + instructions: "Summarise.", + input: "data", + schemaName: "answer", + schema: { type: "object" as const, additionalProperties: false, properties: { headline: { type: "string" } }, required: ["headline"] }, + maxOutputTokens: 100, +}; + +function mockFetch(body: unknown, status = 200) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function lastCall(fetchMock: ReturnType): { url: string; headers: Record; body: Record } { + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + return { url, headers: init.headers as Record, body: JSON.parse(String(init.body)) as Record }; +} + +describe("parseJsonAnswer", () => { + it("accepts plain JSON, fenced JSON and JSON surrounded by prose", () => { + expect(parseJsonAnswer('{"a":1}')).toEqual({ a: 1 }); + expect(parseJsonAnswer('Here you go:\n```json\n{"a":2}\n```')).toEqual({ a: 2 }); + expect(parseJsonAnswer('Sure! {"a":3} hope it helps')).toEqual({ a: 3 }); + expect(() => parseJsonAnswer("nope")).toThrow(/not valid JSON/); + }); +}); + +describe("generateStructured", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("refuses to run without credentials", async () => { + await expect(generateStructured(request, config("openai", { apiKey: null, apiKeySource: "none" }))).rejects.toBeInstanceOf(AiNotConfiguredError); + }); + + it("uses strict JSON schema output with OpenAI", async () => { + const fetchMock = mockFetch({ choices: [{ message: { content: '{"headline":"hi"}' } }] }); + const result = await generateStructured<{ headline: string }>(request, config("openai")); + expect(result).toEqual({ provider: "openai", model: "gpt-4.1-mini", data: { headline: "hi" } }); + const call = lastCall(fetchMock); + expect(call.url).toBe("https://api.openai.com/v1/chat/completions"); + expect(call.headers.Authorization).toBe("Bearer test-key"); + expect(call.body.response_format).toMatchObject({ type: "json_schema", json_schema: { name: "answer", strict: true } }); + }); + + it("falls back to JSON mode plus prompt schema for OpenRouter and adds attribution headers", async () => { + const fetchMock = mockFetch({ choices: [{ message: { content: '```json\n{"headline":"routed"}\n```' } }] }); + const result = await generateStructured<{ headline: string }>(request, config("openrouter")); + expect(result.data.headline).toBe("routed"); + const call = lastCall(fetchMock); + expect(call.url).toBe("https://openrouter.ai/api/v1/chat/completions"); + expect(call.headers["X-Title"]).toBe("Gitdeck"); + expect(call.body.response_format).toEqual({ type: "json_object" }); + expect(String((call.body.messages as Array<{ content: string }>)[0].content)).toContain('"headline"'); + }); + + it("works against a key-less OpenAI-compatible endpoint", async () => { + const fetchMock = mockFetch({ choices: [{ message: { content: '{"headline":"local"}' } }] }); + const result = await generateStructured<{ headline: string }>(request, config("custom", { apiKey: null, apiKeySource: "none", baseUrl: "http://localhost:11434/v1", model: "llama3" })); + expect(result).toMatchObject({ provider: "custom", model: "llama3" }); + const call = lastCall(fetchMock); + expect(call.url).toBe("http://localhost:11434/v1/chat/completions"); + expect(call.headers.Authorization).toBeUndefined(); + }); + + it("forces a tool call with Anthropic and reads the tool input", async () => { + const fetchMock = mockFetch({ content: [{ type: "tool_use", name: "answer", input: { headline: "claude" } }] }); + const result = await generateStructured<{ headline: string }>(request, config("anthropic")); + expect(result.data).toEqual({ headline: "claude" }); + const call = lastCall(fetchMock); + expect(call.url).toBe("https://api.anthropic.com/v1/messages"); + expect(call.headers["x-api-key"]).toBe("test-key"); + expect(call.body.tool_choice).toEqual({ type: "tool", name: "answer" }); + }); + + it("strips unsupported schema keywords for Gemini", async () => { + const fetchMock = mockFetch({ candidates: [{ content: { parts: [{ text: '{"headline":"gemini"}' }] } }] }); + const result = await generateStructured<{ headline: string }>(request, config("gemini")); + expect(result.data).toEqual({ headline: "gemini" }); + const call = lastCall(fetchMock); + expect(call.url).toBe("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"); + expect(call.headers["x-goog-api-key"]).toBe("test-key"); + const generation = call.body.generationConfig as { responseSchema: Record; responseMimeType: string }; + expect(generation.responseMimeType).toBe("application/json"); + expect(generation.responseSchema).not.toHaveProperty("additionalProperties"); + }); + + it("surfaces upstream error messages", async () => { + mockFetch({ error: { message: "invalid model" } }, 400); + await expect(generateStructured(request, config("openai"))).rejects.toThrow(/HTTP 400: invalid model/); + }); +}); diff --git a/tests/server/aiSettings.test.ts b/tests/server/aiSettings.test.ts new file mode 100644 index 0000000..e329378 --- /dev/null +++ b/tests/server/aiSettings.test.ts @@ -0,0 +1,112 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { rm } from "node:fs/promises"; + +const { TMP_DIR } = vi.hoisted(() => { + const { tmpdir } = require("node:os") as typeof import("node:os"); + const { resolve } = require("node:path") as typeof import("node:path"); + return { TMP_DIR: resolve(tmpdir(), `gitdeck-ai-settings-${process.pid}-${Date.now()}`) }; +}); + +vi.mock("../../src/server/config", () => ({ DATA_DIR: TMP_DIR })); + +const settings = await import("../../src/server/ai/settings"); +const { closeDatabase } = await import("../../src/server/sqlite"); + +const AI_ENV = [ + "AI_PROVIDER", "AI_API_KEY", "AI_MODEL", "AI_BASE_URL", + "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_DIGEST_MODEL", + "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "OPENROUTER_MODEL", +]; + +describe("AI settings resolution", () => { + beforeEach(() => { + for (const name of AI_ENV) delete process.env[name]; + settings.resetAiSettings(); + }); + afterEach(() => { + for (const name of AI_ENV) delete process.env[name]; + }); + afterAll(async () => { + closeDatabase(); + await rm(TMP_DIR, { recursive: true, force: true }); + }); + + it("falls back to OpenAI defaults and reports the feature as disabled", () => { + const summary = settings.summarizeAiSettings(); + expect(summary.enabled).toBe(false); + expect(summary.provider).toEqual({ value: "openai", source: "default" }); + expect(summary.apiKey).toEqual({ configured: false, masked: null, source: "none" }); + expect(summary.model).toEqual({ value: "gpt-4.1-mini", source: "default" }); + expect(summary.baseUrl.source).toBe("default"); + }); + + it("auto-detects the provider from environment keys and honours legacy model names", () => { + process.env.ANTHROPIC_API_KEY = "sk-ant-secret-1234"; + let summary = settings.summarizeAiSettings(); + expect(summary.enabled).toBe(true); + expect(summary.provider).toEqual({ value: "anthropic", source: "env" }); + expect(summary.apiKey).toEqual({ configured: true, masked: "sk-…1234", source: "env" }); + + process.env.OPENAI_API_KEY = "sk-openai-secret-9876"; + process.env.OPENAI_DIGEST_MODEL = "gpt-legacy"; + summary = settings.summarizeAiSettings(); + expect(summary.provider.value).toBe("openai"); + expect(summary.model).toEqual({ value: "gpt-legacy", source: "env" }); + }); + + it("applies generic AI_* variables only to the explicitly selected provider", () => { + process.env.AI_API_KEY = "generic-key-0001"; + process.env.AI_MODEL = "some-model"; + expect(settings.summarizeAiSettings().apiKey.configured).toBe(false); + + process.env.AI_PROVIDER = "custom"; + process.env.AI_BASE_URL = "http://ollama.local:11434/v1/"; + const summary = settings.summarizeAiSettings(); + expect(summary.provider).toEqual({ value: "custom", source: "env" }); + expect(summary.apiKey.source).toBe("env"); + expect(summary.model).toEqual({ value: "some-model", source: "env" }); + expect(summary.baseUrl).toEqual({ value: "http://ollama.local:11434/v1", source: "env" }); + expect(summary.enabled).toBe(true); + }); + + it("lets database overrides win over the environment and reports their source", () => { + process.env.OPENAI_API_KEY = "sk-env-key-4242"; + settings.updateAiSettings({ provider: "openrouter", apiKey: "or-db-key-7777", model: "meta-llama/llama-3-70b" }); + + const summary = settings.summarizeAiSettings(); + expect(summary.provider).toEqual({ value: "openrouter", source: "database" }); + expect(summary.apiKey).toEqual({ configured: true, masked: "or-…7777", source: "database" }); + expect(summary.model).toEqual({ value: "meta-llama/llama-3-70b", source: "database" }); + expect(summary.baseUrl).toEqual({ value: "https://openrouter.ai/api/v1", source: "default" }); + const openai = summary.providers.find((entry) => entry.id === "openai"); + expect(openai?.hasEnvKey).toBe(true); + expect(openai?.hasStoredKey).toBe(false); + }); + + it("removes overrides with empty strings and clears everything on reset", () => { + process.env.OPENAI_API_KEY = "sk-env-key-4242"; + settings.updateAiSettings({ provider: "openai", apiKey: "sk-db-key-1111", model: "gpt-x" }); + expect(settings.summarizeAiSettings().apiKey.source).toBe("database"); + + let summary = settings.updateAiSettings({ apiKey: "" }); + expect(summary.apiKey).toEqual({ configured: true, masked: "sk-…4242", source: "env" }); + expect(summary.model.source).toBe("database"); + + summary = settings.resetAiSettings(); + expect(summary.model).toEqual({ value: "gpt-4.1-mini", source: "default" }); + expect(summary.provider.source).toBe("env"); + }); + + it("keeps per-provider overrides when switching provider", () => { + settings.updateAiSettings({ provider: "gemini", apiKey: "gem-key-0001" }); + settings.updateAiSettings({ provider: "openai" }); + const summary = settings.summarizeAiSettings(); + expect(summary.provider.value).toBe("openai"); + expect(summary.providers.find((entry) => entry.id === "gemini")?.hasStoredKey).toBe(true); + }); + + it("rejects invalid base URLs", () => { + expect(() => settings.updateAiSettings({ provider: "custom", baseUrl: "not a url" })).toThrow(settings.AiSettingsValidationError); + expect(() => settings.updateAiSettings({ provider: "custom", baseUrl: "ftp://x" })).toThrow(/http or https/); + }); +}); diff --git a/tests/utils/goals.test.ts b/tests/utils/goals.test.ts new file mode 100644 index 0000000..c243382 --- /dev/null +++ b/tests/utils/goals.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { calculateGoalProgress, formatXThreadForCopy, groupGoalsByRepository } from "../../src/utils/goals"; +import type { RepositoryGoal } from "../../src/types/goals"; + +describe("groupGoalsByRepository", () => { + it("combines goals for the same repository while preserving order", () => { + const goal = (id: string, repository: string) => ({ id, repository }) as RepositoryGoal; + const groups = groupGoalsByRepository([ + goal("stars", "acme/rocket"), + goal("forks", "other/tool"), + goal("downloads", "acme/rocket"), + ]); + + expect(groups.map((group) => group.repository)).toEqual(["acme/rocket", "other/tool"]); + expect(groups[0].goals.map((entry) => entry.id)).toEqual(["stars", "downloads"]); + }); +}); + +describe("formatXThreadForCopy", () => { + it("joins complete X posts with a visible separator", () => { + expect(formatXThreadForCopy({ content: "fallback", threadPosts: [" First post ", "Second post"] })) + .toBe("First post\n\n---\n\nSecond post"); + }); + + it("uses legacy content when structured posts are absent", () => { + expect(formatXThreadForCopy({ content: " Legacy thread ", threadPosts: [] })).toBe("Legacy thread"); + }); +}); + +describe("calculateGoalProgress", () => { + it("calculates bounded progress and remaining time", () => { + expect(calculateGoalProgress( + { currentValue: 75, targetValue: 100, deadline: "2026-02-10" }, + new Date("2026-02-08T12:00:00Z"), + )).toEqual({ percentage: 75, remaining: 25, daysRemaining: 3, completed: false, overdue: false }); + }); + + it("marks completed goals and caps progress", () => { + const result = calculateGoalProgress( + { currentValue: 120, targetValue: 100, deadline: "2020-01-01" }, + new Date("2026-01-01T00:00:00Z"), + ); + expect(result).toMatchObject({ percentage: 100, remaining: 0, completed: true, overdue: false }); + }); + + it("marks unfinished goals past their deadline as overdue", () => { + const result = calculateGoalProgress( + { currentValue: 2, targetValue: 10, deadline: "2025-12-31" }, + new Date("2026-01-01T00:00:00Z"), + ); + expect(result.overdue).toBe(true); + expect(result.daysRemaining).toBe(0); + }); +}); diff --git a/tests/utils/socialProposals.test.ts b/tests/utils/socialProposals.test.ts new file mode 100644 index 0000000..713930b --- /dev/null +++ b/tests/utils/socialProposals.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { hasCompleteSocialSet, normalizeSocialProposals, socialCharacterCount } from "../../src/utils/socialProposals"; + +describe("socialCharacterCount", () => { + it("counts an emoji as one Unicode code point", () => { + expect(socialCharacterCount("Ship it 🚀")).toBe(9); + }); +}); + +describe("normalizeSocialProposals", () => { + const threadPosts = ["Hook", "Problem", "Approach", "Evidence", "Call to action"]; + + it("returns one valid draft per required platform and rebuilds thread content", () => { + const result = normalizeSocialProposals([ + { title: "X", format: "x-thread", summary: "Developers; README angle.", content: "wrong", threadPosts }, + { title: "LinkedIn", format: "linkedin-post", summary: "Technical leaders; project value.", content: "A professional post.", threadPosts: [] }, + { title: "Mastodon", format: "mastodon-post", summary: "OSS community; contribution angle.", content: "A community post.", threadPosts: [] }, + ]); + + expect(result).toHaveLength(3); + expect(result[0].content).toBe(threadPosts.join("\n\n---\n\n")); + expect(hasCompleteSocialSet(result)).toBe(true); + }); + + it("rejects duplicate formats and posts that exceed platform limits", () => { + const result = normalizeSocialProposals([ + { title: "First", format: "mastodon-post", summary: "Community audience; project angle.", content: "Valid", threadPosts: [] }, + { title: "Duplicate", format: "mastodon-post", summary: "Community audience; project angle.", content: "Also valid", threadPosts: [] }, + { title: "Too long", format: "x-thread", summary: "Developer audience; project angle.", content: "", threadPosts: [...threadPosts.slice(0, 4), "x".repeat(281)] }, + ]); + + expect(result.map((proposal) => proposal.title)).toEqual(["First"]); + expect(hasCompleteSocialSet(result)).toBe(false); + }); +}); From 5eb8aa1735ccfa067e638771e58f63dfd826616b Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 08:35:15 +0200 Subject: [PATCH 02/54] feat: add repository source libraries to social plans Persist a fixed source library for each repository and expose it through a dedicated Growth Studio modal. Generated platform drafts now reuse source text, attach verified source media per post, identify the active AI model, and include an angle based on recent project updates. --- src/api/github.ts | 26 ++- src/components/common/ContentSourcePicker.tsx | 130 ++++++++++++ .../common/RepositoryContentSources.tsx | 102 ++++++++++ src/components/common/RepositoryPicker.tsx | 11 +- src/components/modals/GoalProposalsModal.tsx | 70 ++++++- src/components/views/GoalsView.tsx | 6 +- src/i18n/en.ts | 20 ++ src/i18n/it.ts | 20 ++ src/server/goalStore.ts | 36 +++- src/server/goals.ts | 185 ++++++++++++++++-- src/server/routes/goals.ts | 15 +- src/server/routes/repository.ts | 22 ++- src/styles/goals.css | 63 +++++- src/types/goals.ts | 14 ++ src/utils/socialProposals.ts | 131 ++++++++++++- tests/utils/socialProposals.test.ts | 60 +++++- 16 files changed, 867 insertions(+), 44 deletions(-) create mode 100644 src/components/common/ContentSourcePicker.tsx create mode 100644 src/components/common/RepositoryContentSources.tsx diff --git a/src/api/github.ts b/src/api/github.ts index 4bb1f42..e6a982d 100644 --- a/src/api/github.ts +++ b/src/api/github.ts @@ -1,7 +1,7 @@ import { interpretUpstreamJson } from "../utils/upstreamResponse"; import { getEtag, peek, setEtag, swr } from "./cache"; import type { AiConnectionTest, AiSettingsSummary, AiSettingsUpdate } from "../types/ai"; -import type { GoalMetric, GoalProposalsData, GoalsData, GoalSuggestion, RepositoryGoal } from "../types/goals"; +import type { GoalContentSource, GoalMetric, GoalProposalsData, GoalsData, GoalSuggestion, RepositoryGoal } from "../types/goals"; import type { ApiError, CIHealthData, @@ -214,9 +214,29 @@ export function generateGoalAdvice(id: string): Promise<{ ok: true; suggestions: return readJson(`/api/goals/${encodeURIComponent(id)}/advice`, { method: "POST" }); } -export function fetchGoalProposals(goalId: string, suggestionIndex: number, refresh = false): Promise { +export function fetchRepositoryContentSources(repository: string): Promise<{ ok: true; sources: GoalContentSource[] }> { + return readJson(`/api/repository-content-sources?repo=${encodeURIComponent(repository)}`); +} + +export function updateRepositoryContentSources(repository: string, sources: GoalContentSource[]): Promise<{ ok: true; sources: GoalContentSource[] }> { + return readJson(`/api/repository-content-sources?repo=${encodeURIComponent(repository)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sources }), + }); +} + +export function fetchGoalProposals( + goalId: string, + suggestionIndex: number, + refresh = false, +): Promise { const query = refresh ? "?refresh=1" : ""; - return readJson(`/api/goals/${encodeURIComponent(goalId)}/suggestions/${suggestionIndex}/proposals${query}`, { method: "POST" }); + return readJson(`/api/goals/${encodeURIComponent(goalId)}/suggestions/${suggestionIndex}/proposals${query}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); } export function fetchAiSettings(): Promise<{ ok: true; settings: AiSettingsSummary }> { diff --git a/src/components/common/ContentSourcePicker.tsx b/src/components/common/ContentSourcePicker.tsx new file mode 100644 index 0000000..b56b0ce --- /dev/null +++ b/src/components/common/ContentSourcePicker.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useState, type KeyboardEvent } from "react"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { GoalContentSource } from "../../types/goals"; +import type { GhRepo } from "../../types/github"; +import { normalizeContentSources } from "../../utils/socialProposals"; +import { BookIcon } from "./Icons"; +import { RepositoryPicker } from "./RepositoryPicker"; + +interface ContentSourcePickerProps { + repos: GhRepo[]; + currentRepository: string; + value: GoalContentSource[]; + onChange: (sources: GoalContentSource[]) => void; + maxSources?: number; +} + +type SourceMode = "repository" | "website"; + +/** Selects optional campaign sources without duplicating repository-picker behavior. */ +export function ContentSourcePicker({ repos, currentRepository, value, onChange, maxSources = 6 }: ContentSourcePickerProps) { + const { t } = useI18n(); + const [mode, setMode] = useState("repository"); + const [repository, setRepository] = useState(""); + const [website, setWebsite] = useState(""); + const [error, setError] = useState(""); + const full = value.length >= maxSources; + const availableRepos = useMemo(() => full ? [] : repos.filter((repo) => ( + repo.nameWithOwner !== currentRepository + && !value.some((source) => source.type === "repository" && source.value === repo.nameWithOwner) + )), [currentRepository, full, repos, value]); + + function add(source: GoalContentSource): boolean { + const normalized = normalizeContentSources([...value, source], maxSources); + if (normalized.length === value.length) { + setError(t("goals.sourcesInvalid")); + return false; + } + onChange(normalized); + setError(""); + return true; + } + + useEffect(() => { + if (!repository) return; + add({ type: "repository", value: repository }); + setRepository(""); + // `add` intentionally reacts only to an explicit picker selection. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repository]); + + function addWebsite() { + if (add({ type: "website", value: website })) setWebsite(""); + } + + function handleWebsiteKeyDown(event: KeyboardEvent) { + if (event.key !== "Enter") return; + event.preventDefault(); + addWebsite(); + } + + return ( +
+
+ +
+ {t("goals.sourcesTitle")} + {t("goals.sourcesDescription")} +
+ {value.length}/{maxSources} +
+ +
+
+ + +
+
+ {mode === "repository" ? ( + + ) : ( +
+ + setWebsite(event.target.value)} + onKeyDown={handleWebsiteKeyDown} + /> + +
+ )} +
+
+ + {value.length ? ( +
+ {value.map((source) => ( + + {source.type === "repository" ? t("goals.sourcesRepoBadge") : t("goals.sourcesWebBadge")} + {source.value} + + + ))} +
+ ) : ( +

{t("goals.sourcesOptional")}

+ )} + {error ? {error} : null} +
+ ); +} diff --git a/src/components/common/RepositoryContentSources.tsx b/src/components/common/RepositoryContentSources.tsx new file mode 100644 index 0000000..38bea40 --- /dev/null +++ b/src/components/common/RepositoryContentSources.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { fetchRepositoryContentSources, updateRepositoryContentSources } from "../../api/github"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { GoalContentSource } from "../../types/goals"; +import type { GhRepo } from "../../types/github"; +import { ContentSourcePicker } from "./ContentSourcePicker"; +import { BookIcon, CloseIcon } from "./Icons"; + +interface RepositoryContentSourcesProps { + repository: string; + repos: GhRepo[]; +} + +/** Opens the fixed source library shared by every generated post for a repository. */ +export function RepositoryContentSources({ repository, repos }: RepositoryContentSourcesProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const saveQueue = useRef>(Promise.resolve()); + const saveVersion = useRef(0); + + useEffect(() => { + if (!open) return; + let active = true; + saveVersion.current += 1; + setError(""); + setLoading(true); + setSaving(false); + void fetchRepositoryContentSources(repository) + .then((result) => { if (active) setSources(result.sources); }) + .catch((cause) => { if (active) setError((cause as Error).message); }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [open, repository]); + + useEffect(() => { + if (!open) return; + function closeOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [open]); + + function changeSources(next: GoalContentSource[]) { + setSources(next); + setError(""); + setSaving(true); + const version = ++saveVersion.current; + const request = saveQueue.current.then(async () => { + await updateRepositoryContentSources(repository, next); + }); + saveQueue.current = request.catch(() => undefined); + void request.catch((cause) => { + if (version === saveVersion.current) setError((cause as Error).message); + }).finally(() => { + if (version === saveVersion.current) setSaving(false); + }); + } + + return ( + <> + + {open ? createPortal( +
+
setOpen(false)} /> +
+
+
+ +
+
{repository}
+

{t("goals.sourcesTitle")}

+
+
+ +
+
+ {loading ?
{t("common.loading")}
: ( + + )} + {saving ? {t("common.loading")} : null} + {error ? {error} : null} +
+
+ {saving ? t("common.loading") : ""} +
+ +
+
+
, + document.body, + ) : null} + + ); +} diff --git a/src/components/common/RepositoryPicker.tsx b/src/components/common/RepositoryPicker.tsx index fbcc32b..1d48652 100644 --- a/src/components/common/RepositoryPicker.tsx +++ b/src/components/common/RepositoryPicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { GhRepo } from "../../types/github"; import { formatNumber } from "../../utils/format"; import { BookIcon } from "./Icons"; @@ -12,6 +12,7 @@ interface RepositoryPickerProps { export function RepositoryPicker({ repos, value, placeholder, onChange }: RepositoryPickerProps) { const rootRef = useRef(null); + const optionsId = useId(); const [query, setQuery] = useState(value); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); @@ -46,8 +47,8 @@ export function RepositoryPicker({ repos, value, placeholder, onChange }: Reposi type="search" role="combobox" aria-expanded={open} - aria-controls="repository-picker-options" - aria-activedescendant={open && matches[activeIndex] ? `repo-option-${activeIndex}` : undefined} + aria-controls={optionsId} + aria-activedescendant={open && matches[activeIndex] ? `${optionsId}-option-${activeIndex}` : undefined} autoComplete="off" placeholder={placeholder} value={query} @@ -68,14 +69,14 @@ export function RepositoryPicker({ repos, value, placeholder, onChange }: Reposi
{open ? ( -
+
{matches.length ? `${matches.length} repositories` : "No repositories found"}
{matches.map((repo, index) => (
@@ -165,12 +216,17 @@ export function GoalProposalsModal({ goal, suggestion, suggestionIndex, onClose, {state.kind === "ready" && state.generatedAt ? t("goals.proposalsGeneratedAt", { time: formatRelativeTime(state.generatedAt, Date.now(), language) }) : ""}
- {state.kind === "ready" || state.kind === "error" ? ( + {state.kind === "ready" ? ( ) : null} - + + {state.kind === "idle" || state.kind === "error" ? ( + + ) : null}
, diff --git a/src/components/views/GoalsView.tsx b/src/components/views/GoalsView.tsx index d07e92e..2a92075 100644 --- a/src/components/views/GoalsView.tsx +++ b/src/components/views/GoalsView.tsx @@ -4,6 +4,7 @@ import { createGoal, deleteGoal, generateGoalAdvice } from "../../api/github"; import { useI18n } from "../../i18n/I18nProvider"; import { Avatar } from "../common/Avatar"; import { ConfirmDialog } from "../common/ConfirmDialog"; +import { RepositoryContentSources } from "../common/RepositoryContentSources"; import { RepositoryPicker } from "../common/RepositoryPicker"; import { GoalIcon } from "../common/Icons"; import { GoalProposalsModal } from "../modals/GoalProposalsModal"; @@ -178,7 +179,10 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) {
{t("goals.growthStudioEyebrow")}

{t("goals.growthStudio")}

-

{t("goals.growthStudioDescription")}

+
+

{t("goals.growthStudioDescription")}

+ +
{group.goals.map((goal) => ( diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9d9f2bc..08863ce 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -177,8 +177,28 @@ export const en = { "goals.proposalsOpen": "Get proposals", "goals.proposalsKind": "Recommended action", "goals.proposalsIntro": "Ready-to-use drafts for this action, based on the README and current activity of {repo}.", + "goals.sourcesTitle": "Repository sources", + "goals.sourcesDescription": "Set the repository's shared source library. Each post can use different text, images, and videos from these fixed project sources. External reuse rights must be checked before publishing.", + "goals.sourcesRepository": "Repository source", + "goals.sourcesChooseRepository": "Choose another repository…", + "goals.sourcesWebsite": "Website source", + "goals.sourcesAdd": "Add", + "goals.sourcesRepoBadge": "Repo", + "goals.sourcesWebBadge": "Web", + "goals.sourcesRemove": "Remove {source}", + "goals.sourcesInvalid": "Enter a valid, non-duplicate website or repository.", + "goals.sourcesLimit": "Maximum number of sources reached", + "goals.sourcesOptional": "Optional — saved for this project and shared by all its posts. The project repository remains the primary source.", + "goals.mediaTitle": "Suggested visual assets", + "goals.mediaImage": "Image", + "goals.mediaVideo": "Video", + "goals.proposalsReadyTitle": "Ready when you are", + "goals.proposalsReadyText": "Review the action, then generate drafts using the repository's fixed source library.", + "goals.proposalsGenerate": "Generate proposals", + "goals.proposalsRetry": "Try again", "goals.proposalsLoading": "Reading the project and drafting proposals…", "goals.proposalsRegenerate": "Regenerate", + "goals.proposalsRegenerateSources": "Regenerate with sources", "goals.proposalsGeneratedAt": "Generated {time}", "goals.proposalsNoAi": "Configure an AI provider in Preferences to get proposals.", "goals.proposalsOpenPreferences": "Open preferences", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 08dbeff..9ff6f54 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -179,8 +179,28 @@ export const it: Record = { "goals.proposalsOpen": "Ottieni proposte", "goals.proposalsKind": "Intervento consigliato", "goals.proposalsIntro": "Bozze pronte all'uso per questo intervento, basate sul README e sull'attività attuale di {repo}.", + "goals.sourcesTitle": "Fonti della repository", + "goals.sourcesDescription": "Imposta le fonti condivise della repository. Ogni post può riprendere testi, immagini e video diversi da queste fonti fisse per il progetto. Verifica i diritti di riutilizzo prima della pubblicazione.", + "goals.sourcesRepository": "Repository sorgente", + "goals.sourcesChooseRepository": "Scegli un'altra repository…", + "goals.sourcesWebsite": "Sito web sorgente", + "goals.sourcesAdd": "Aggiungi", + "goals.sourcesRepoBadge": "Repo", + "goals.sourcesWebBadge": "Web", + "goals.sourcesRemove": "Rimuovi {source}", + "goals.sourcesInvalid": "Inserisci un sito o una repository validi e non duplicati.", + "goals.sourcesLimit": "Numero massimo di fonti raggiunto", + "goals.sourcesOptional": "Facoltativo — vengono salvate per il progetto e condivise da tutti i suoi post. La repository resta la fonte principale.", + "goals.mediaTitle": "Contenuti visuali suggeriti", + "goals.mediaImage": "Immagine", + "goals.mediaVideo": "Video", + "goals.proposalsReadyTitle": "Tutto pronto", + "goals.proposalsReadyText": "Controlla l'intervento e genera le bozze usando la libreria di fonti fisse della repository.", + "goals.proposalsGenerate": "Genera proposte", + "goals.proposalsRetry": "Riprova", "goals.proposalsLoading": "Sto leggendo il progetto e preparando le proposte…", "goals.proposalsRegenerate": "Rigenera", + "goals.proposalsRegenerateSources": "Rigenera con le fonti", "goals.proposalsGeneratedAt": "Generate {time}", "goals.proposalsNoAi": "Configura un provider AI nelle Preferenze per ottenere proposte.", "goals.proposalsOpenPreferences": "Apri preferenze", diff --git a/src/server/goalStore.ts b/src/server/goalStore.ts index 29303e4..e8abfe7 100644 --- a/src/server/goalStore.ts +++ b/src/server/goalStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; +import type { GoalContentSource, GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; import { all, get, getDatabase, run } from "./sqlite"; interface GoalRow { @@ -33,6 +33,13 @@ function ensureSchema(): void { ); CREATE INDEX IF NOT EXISTS repository_goals_account_deadline ON repository_goals(account_id, deadline); + CREATE TABLE IF NOT EXISTS repository_content_sources ( + account_id TEXT NOT NULL, + repository TEXT NOT NULL, + sources TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL, + PRIMARY KEY (account_id, repository) + ); `); } @@ -90,6 +97,33 @@ export function updateGoalCurrentValue(accountId: string, id: string, currentVal run("UPDATE repository_goals SET current_value = ?, updated_at = ? WHERE account_id = ? AND id = ?", [currentValue, new Date().toISOString(), accountId, id]); } +/** Returns the shared source library used by every goal and post for a repository. */ +export function getRepositoryContentSources(accountId: string, repository: string): GoalContentSource[] { + ensureSchema(); + const row = get<{ sources: string }>( + "SELECT sources FROM repository_content_sources WHERE account_id = ? AND repository = ?", + [accountId, repository], + ); + if (!row) return []; + try { + const sources = JSON.parse(row.sources) as unknown; + return Array.isArray(sources) ? sources as GoalContentSource[] : []; + } catch { + return []; + } +} + +/** Replaces a repository's fixed source library. Inputs are normalized by the route. */ +export function saveRepositoryContentSources(accountId: string, repository: string, sources: GoalContentSource[]): void { + ensureSchema(); + run( + `INSERT INTO repository_content_sources (account_id, repository, sources, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(account_id, repository) DO UPDATE SET sources = excluded.sources, updated_at = excluded.updated_at`, + [accountId, repository, JSON.stringify(sources), new Date().toISOString()], + ); +} + export function saveGoalSuggestions(accountId: string, id: string, suggestions: GoalSuggestion[]): void { ensureSchema(); const now = new Date().toISOString(); diff --git a/src/server/goals.ts b/src/server/goals.ts index d599f59..9d3a25e 100644 --- a/src/server/goals.ts +++ b/src/server/goals.ts @@ -1,6 +1,15 @@ -import type { GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import type { GoalContentSource, GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; import { calculateGoalProgress } from "../utils/goals"; -import { hasCompleteSocialSet, normalizeSocialProposals, SOCIAL_PROPOSAL_FORMATS } from "../utils/socialProposals"; +import { + attachSourceMedia, + extractMediaUrls, + extractWebPageSignal, + hasCompleteSocialSet, + normalizeSocialProposals, + SOCIAL_PROPOSAL_FORMATS, +} from "../utils/socialProposals"; import { AiNotConfiguredError, AiRequestError, generateStructured } from "./ai/client"; import { isAiConfigured } from "./ai/settings"; import { getIssuesCached, getPullRequestsCached, getReposCached } from "./dashboardData"; @@ -71,17 +80,23 @@ function fallbackSuggestions(goal: Omit): GoalSugge { category: "marketing", title: "Publish a complete X launch thread", - action: `Tell the story of ${goal.repository} in a 5–7 post X thread: open with a concrete hook, show what the project solves, highlight recent work, share the ${progress.percentage}% goal progress, and close with one clear call to action.`, + action: `Tell the story of ${goal.repository} in a 5–7 post X thread: open with a concrete hook, show what the project solves, highlight recent work, share the ${progress.percentage}% goal progress, and close with one clear call to action.`, + }, + { + category: "marketing", + title: "Build the next campaign from the latest updates", + action: "Use the latest verified release notes, issue activity, and merged work as the campaign narrative. Explain what changed, why it matters, and invite the community to try it or contribute without claiming that unfinished work has shipped.", }, ]; } export async function generateGoalSuggestions(goal: Omit): Promise { if (!isAiConfigured()) return fallbackSuggestions(goal); - const [issuesResult, prsResult, reposResult] = await Promise.all([ + const [issuesResult, prsResult, reposResult, releases] = await Promise.all([ getIssuesCached(false), getPullRequestsCached(false), getReposCached(false), + fetchReleaseSignals(goal.repository), ]); const issues = issuesResult.ok ? issuesResult.issues.filter((item) => item.repository.nameWithOwner === goal.repository) : []; const prs = prsResult.ok ? prsResult.pullRequests.filter((item) => item.repository.nameWithOwner === goal.repository) : []; @@ -90,7 +105,7 @@ export async function generateGoalSuggestions(goal: Omit Date.now() - new Date(item.updatedAt).getTime() > 30 * 86_400_000).length; const result = await generateStructured<{ suggestions: GoalSuggestion[] }>({ - instructions: "Act as an open-source growth and social strategist. Give specific, ethical actions grounded in the supplied activity. Include at least one substantial social campaign idea designed as a complete 5–7 post X thread, not a generic one-line post. Give it a strong hook, a useful narrative arc, concrete project details, and one clear call to action. Return JSON only.", + instructions: "Act as an open-source growth and social strategist. Give specific, ethical actions grounded in the supplied activity. Include at least one substantial social campaign idea designed as a complete 5–7 post X thread, not a generic one-line post. Also include one recommendation explicitly based on the latest verified updates (releases, recently updated issues, or pull requests), clearly framing unfinished work as work in progress. Give it a strong hook, a useful narrative arc, concrete project details, and one clear call to action. Return JSON only.", input: JSON.stringify({ repository: goal.repository, description: repo?.description, @@ -102,8 +117,13 @@ export async function generateGoalSuggestions(goal: Omit item.title), - recentPullRequestTitles: prs.slice(0, 5).map((item) => item.title), + recentIssues: issues.slice(0, 8).map((item) => ({ title: item.title, updatedAt: item.updatedAt })), + recentPullRequests: prs.slice(0, 5).map((item) => ({ title: item.title, updatedAt: item.updatedAt })), + latestReleases: releases.map((release) => ({ + name: release.name || release.tag_name || null, + publishedAt: release.published_at ?? null, + notesExcerpt: release.body?.replace(/\s+/g, " ").trim().slice(0, 350) || null, + })), }), schemaName: "goal_actions", schema: { @@ -134,7 +154,7 @@ export async function generateGoalSuggestions(goal: Omit } } -async function fetchReadmeExcerpt(repository: string): Promise { +interface ReadmeSignal { + excerpt: string; + mediaUrls: string[]; +} + +async function fetchReadmeSignal(repository: string): Promise { try { - const result = await restApi<{ content?: string; encoding?: string }>(`/repos/${repository}/readme`); + const result = await restApi<{ content?: string; encoding?: string; download_url?: string | null }>(`/repos/${repository}/readme`); if (!result.ok || !result.data?.content) return null; const text = result.data.encoding === "base64" ? Buffer.from(result.data.content, "base64").toString("utf-8") : result.data.content; - return text.replace(/\r/g, "").trim().slice(0, README_EXCERPT_CHARS) || null; + return { + excerpt: text.replace(/\r/g, "").trim().slice(0, README_EXCERPT_CHARS), + mediaUrls: extractMediaUrls(text, result.data.download_url), + }; } catch { return null; } } +function isPrivateAddress(address: string): boolean { + if (isIP(address) === 4) { + const [a, b] = address.split(".").map(Number); + return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) + || (a === 198 && (b === 18 || b === 19)) || a >= 224; + } + const normalized = address.toLowerCase(); + return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") + || /^fe[89ab]/.test(normalized) || normalized.startsWith("::ffff:") && isPrivateAddress(normalized.slice(7)); +} + +async function assertPublicWebsite(url: URL): Promise { + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error("unsupported source URL"); + if (url.hostname === "localhost" || url.hostname.endsWith(".localhost")) throw new Error("private source URL"); + const addresses = isIP(url.hostname) + ? [{ address: url.hostname }] + : await lookup(url.hostname, { all: true, verbatim: true }); + if (!addresses.length || addresses.some((entry) => isPrivateAddress(entry.address))) throw new Error("private source URL"); +} + +async function readBoundedText(response: Response, maxBytes = 600_000): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { await reader.cancel(); break; } + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); +} + +async function fetchWebsiteSignal(value: string): Promise { + try { + let url = new URL(value); + for (let redirects = 0; redirects <= 3; redirects += 1) { + await assertPublicWebsite(url); + const response = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(7_000), + headers: { Accept: "text/html,text/plain,image/*,video/*", "User-Agent": "GitDeck/1.0 source-reader" }, + }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location || redirects === 3) throw new Error("too many source redirects"); + url = new URL(location, url); + continue; + } + if (!response.ok) throw new Error(`source returned ${response.status}`); + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.startsWith("image/") || contentType.startsWith("video/")) { + return { type: "website", url: value, title: null, excerpt: null, mediaUrls: [url.toString()] }; + } + if (!contentType.includes("text/html") && !contentType.includes("text/plain")) throw new Error("unsupported source content"); + const page = extractWebPageSignal(await readBoundedText(response), url.toString()); + return { type: "website", url: value, title: page.title, excerpt: page.excerpt, mediaUrls: page.mediaUrls }; + } + } catch (error) { + return { type: "website", url: value, error: (error as Error).message }; + } + return { type: "website", url: value, error: "source unavailable" }; +} + +async function fetchAdditionalSourceSignals(sources: GoalContentSource[]): Promise { + return Promise.all(sources.map(async (source) => { + if (source.type === "website") return fetchWebsiteSignal(source.value); + const [readme, releases] = await Promise.all([ + fetchReadmeSignal(source.value), + fetchReleaseSignals(source.value), + ]); + return { + type: source.type, + repository: source.value, + readmeExcerpt: readme?.excerpt ?? null, + mediaUrls: readme?.mediaUrls ?? [], + releases: releases.map((release) => ({ + name: release.name || release.tag_name || null, + url: release.html_url ?? null, + publishedAt: release.published_at ?? null, + notesExcerpt: release.body?.replace(/\s+/g, " ").trim().slice(0, 500) || null, + })), + }; + })); +} + /** * Turns one recommended action into concrete, ready-to-use deliverables * (posts, issue drafts, checklists…) grounded in the repository's README and * current activity. Requires a configured AI provider. */ -export async function generateGoalProposals(goal: Omit, suggestion: GoalSuggestion): Promise { +export async function generateGoalProposals( + goal: Omit, + suggestion: GoalSuggestion, + sources: GoalContentSource[] = [], +): Promise { if (!isAiConfigured()) throw new AiNotConfiguredError(); - const [issuesResult, prsResult, reposResult, readme, releases] = await Promise.all([ + const [issuesResult, prsResult, reposResult, readme, releases, additionalSources] = await Promise.all([ getIssuesCached(false), getPullRequestsCached(false), getReposCached(false), - fetchReadmeExcerpt(goal.repository), + fetchReadmeSignal(goal.repository), fetchReleaseSignals(goal.repository), + fetchAdditionalSourceSignals(sources), ]); const issues = issuesResult.ok ? issuesResult.issues.filter((item) => item.repository.nameWithOwner === goal.repository) : []; const prs = prsResult.ok ? prsResult.pullRequests.filter((item) => item.repository.nameWithOwner === goal.repository) : []; @@ -202,18 +325,21 @@ export async function generateGoalProposals(goal: Omit { + if (!source || typeof source !== "object") return []; + const mediaUrls = (source as { mediaUrls?: unknown }).mediaUrls; + return Array.isArray(mediaUrls) ? mediaUrls.filter((url): url is string => typeof url === "string") : []; + }), + ]; + if (proposals.length === 3 && hasCompleteSocialSet(proposals)) return attachSourceMedia(proposals, sourceMedia); feedback = "The previous answer was not publishable. Return all three required formats exactly once; use 5–7 X posts of at most 280 characters, LinkedIn content of at most 3000 characters, and Mastodon content of at most 500 characters."; } throw new AiRequestError("AI returned incomplete or platform-invalid social proposals"); diff --git a/src/server/routes/goals.ts b/src/server/routes/goals.ts index 77fa9fd..7f9d3ac 100644 --- a/src/server/routes/goals.ts +++ b/src/server/routes/goals.ts @@ -1,5 +1,13 @@ import { getActive as getActiveAccount } from "../accountStore"; -import { createGoal, deleteGoal, findGoal, listGoals, saveGoalProposals, saveGoalSuggestions } from "../goalStore"; +import { + createGoal, + deleteGoal, + findGoal, + getRepositoryContentSources, + listGoals, + saveGoalProposals, + saveGoalSuggestions, +} from "../goalStore"; import { isAiConfigured } from "../ai/settings"; import { AiNotConfiguredError, AiRequestError } from "../ai/client"; import { generateGoalProposals, generateGoalSuggestions, refreshGoal, SOCIAL_PROPOSALS_VERSION } from "../goals"; @@ -76,12 +84,15 @@ async function proposals(ctx: RouteContext): Promise { const index = Number(ctx.params.index); const suggestion = Number.isInteger(index) ? goal.suggestions[index] : undefined; if (!suggestion) return sendJson(ctx.res, 404, { ok: false, error: "suggestion not found" }); + const body = await parseJsonBody>(ctx.req, ctx.res); + if (!body) return; + const sources = getRepositoryContentSources(account.id, goal.repository); const refresh = ctx.url.searchParams.get("refresh") === "1"; if (!refresh && suggestion.proposals?.length && suggestion.proposalsVersion === SOCIAL_PROPOSALS_VERSION) { return sendJson(ctx.res, 200, { ok: true, proposals: suggestion.proposals, generatedAt: suggestion.proposalsGeneratedAt, cached: true }); } try { - const generated = await generateGoalProposals(goal, suggestion); + const generated = await generateGoalProposals(goal, suggestion, sources); if (!generated.length) return sendJson(ctx.res, 502, { ok: false, error: "AI returned no proposals" }); const saved = saveGoalProposals(account.id, goal.id, index, generated, SOCIAL_PROPOSALS_VERSION); sendJson(ctx.res, 200, { ok: true, proposals: generated, generatedAt: saved?.proposalsGeneratedAt ?? new Date().toISOString(), cached: false }); diff --git a/src/server/routes/repository.ts b/src/server/routes/repository.ts index a475abb..afb83b6 100644 --- a/src/server/routes/repository.ts +++ b/src/server/routes/repository.ts @@ -1,5 +1,8 @@ import type { RepoSecuritySummary } from "../../types/github"; +import { normalizeContentSources } from "../../utils/socialProposals"; +import { getActive as getActiveAccount } from "../accountStore"; import { ghApiJson, gql, restApiPaginate, type RestResult } from "../githubClient"; +import { getRepositoryContentSources, saveRepositoryContentSources } from "../goalStore"; import { getLatestRepoDigest } from "../digests"; import { BRANCHES_QUERY, @@ -9,7 +12,7 @@ import { REPO_COUNTS_QUERY, STARGAZERS_QUERY, } from "../graphql/repositoryQueries"; -import { sendJson } from "../http"; +import { parseJsonBody, sendJson } from "../http"; import type { AppRouter, RouteContext } from "../router"; import { fetchRepoSecuritySummary } from "../securityAlerts"; import { requireRepo, requireRepoParts, sendError } from "./shared"; @@ -302,10 +305,27 @@ async function details(ctx: RouteContext): Promise { }); } +async function contentSources(ctx: RouteContext): Promise { + const account = await getActiveAccount(); + if (!account) return sendJson(ctx.res, 401, { ok: false, needsAuth: true, error: "authentication required" }); + const repository = requireRepo(ctx); + if (!repository) return; + if (ctx.req.method === "GET") { + return sendJson(ctx.res, 200, { ok: true, sources: getRepositoryContentSources(account.id, repository) }); + } + const body = await parseJsonBody<{ sources?: unknown }>(ctx.req, ctx.res); + if (!body) return; + const sources = normalizeContentSources(body.sources); + saveRepositoryContentSources(account.id, repository, sources); + sendJson(ctx.res, 200, { ok: true, sources }); +} + export function registerRepositoryRoutes(router: AppRouter): void { router.get("/api/stargazers", stargazers); router.get("/api/forks", forks); router.get("/api/repo-branches", branches); router.get("/api/repo-discussions", discussions); router.get("/api/repo-details", details); + router.get("/api/repository-content-sources", contentSources); + router.on("PUT", "/api/repository-content-sources", contentSources); } diff --git a/src/styles/goals.css b/src/styles/goals.css index df4f810..8c2b89a 100644 --- a/src/styles/goals.css +++ b/src/styles/goals.css @@ -17,8 +17,8 @@ .repository-picker-input { display: flex; align-items: center; min-height: 36px; padding: 0 9px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; transition: border-color .12s, box-shadow .12s; } .repository-picker-input.open { border-color: var(--accent); box-shadow: var(--ring); } .repository-picker-input > svg { width: 14px; height: 14px; flex: 0 0 auto; fill: none; stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; } -.goal-form .repository-picker-input input { min-width: 0; min-height: 34px; padding: 6px 8px; background: transparent; border: 0; box-shadow: none; } -.goal-form .repository-picker-input input:focus { border: 0; box-shadow: none; } +.repository-picker-input input { width: 100%; min-width: 0; min-height: 34px; padding: 6px 8px; color: var(--text); background: transparent; border: 0; outline: 0; box-shadow: none; } +.repository-picker-input input:focus { border: 0; outline: 0; box-shadow: none; } .repository-picker-chevron { color: var(--muted); font-size: 15px; } .repository-picker-menu { position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; width: max(100%, 430px); max-width: min(90vw, 560px); max-height: 390px; overflow-y: auto; padding: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 14px 40px rgba(0,0,0,.35); } .repository-picker-summary { padding: 6px 8px 8px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } @@ -103,6 +103,11 @@ .goal-studio-heading span { color: var(--accent); font-size: 9px; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; } .goal-studio-heading h3 { margin: 2px 0 0; font-size: 15px; } .goal-studio-heading p { max-width: 520px; margin: 0; color: var(--muted); font-size: 11px; text-align: right; } +.goal-studio-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; } +.goal-sources-open { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; } +.goal-sources-open svg { width: 14px; height: 14px; } +.modal.goal-sources-modal { width: min(720px, calc(100vw - 32px)); height: auto; max-height: min(86vh, 720px); } +.goal-sources-body { display: grid; gap: 10px; padding: 18px; } .goal-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 10px; } .goal-plan { overflow: hidden; background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--border-soft); border-radius: 12px; } .goal-plan-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; background: linear-gradient(90deg, var(--accent-faint), transparent); border-bottom: 1px solid var(--border-soft); } @@ -136,6 +141,7 @@ @media (max-width: 900px) { .goal-form { grid-template-columns: 1fr 1fr; } .goal-studio-heading { display: grid; } + .goal-studio-actions { align-items: flex-start; justify-content: space-between; } .goal-studio-heading p { text-align: left; } } @media (max-width: 560px) { @@ -145,14 +151,54 @@ .goal-repository-score small { display: none; } .goal-track-grid { padding: 10px; } .goal-growth-studio { padding: 15px 10px; } + .goal-studio-actions { display: grid; justify-items: start; } } /* Proposals modal */ .modal.goal-proposals-modal { width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(90vh, 960px); } .goal-proposals-category { color: var(--accent-2); text-transform: uppercase; } +.goal-proposals-model { color: var(--muted); font-weight: 600; text-transform: none; } .goal-proposals-body { display: grid; gap: 14px; padding: 18px; } .goal-proposals-intro { margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.5; } .goal-proposals-action { margin: 0; padding: 10px 14px; border-left: 3px solid var(--accent); border-radius: 0 8px 8px 0; background: var(--panel-2); color: var(--text); font-size: 12.5px; line-height: 1.5; } +.content-source-picker { display: grid; gap: 11px; padding: 14px; border: 1px solid var(--border-soft); border-radius: 12px; background: linear-gradient(145deg, color-mix(in srgb, var(--panel-2) 82%, var(--accent) 3%), var(--panel-2)); } +.content-source-head { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; } +.content-source-icon { display: grid; place-items: center; width: 34px; height: 34px; color: var(--accent-2); background: var(--accent-faint); border: 1px solid var(--accent-border); border-radius: 9px; } +.content-source-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.content-source-head > div { display: grid; gap: 2px; } +.content-source-head strong { font-size: 12.5px; } +.content-source-head small { max-width: 680px; color: var(--muted); font-size: 10.5px; line-height: 1.4; } +.content-source-count { align-self: start; padding: 3px 7px; color: var(--muted); background: var(--panel); border: 1px solid var(--border-soft); border-radius: 999px; font-size: 9.5px; } +.content-source-compose { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; } +.content-source-tabs { display: inline-flex; padding: 3px; background: var(--panel); border: 1px solid var(--border); border-radius: 9px; } +.content-source-tabs button { display: inline-flex; align-items: center; gap: 5px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 10.5px; font-weight: 700; cursor: pointer; } +.content-source-tabs button[aria-selected="true"] { color: var(--text); background: var(--hover-surface); box-shadow: 0 1px 3px rgba(0,0,0,.18); } +.content-source-tabs svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; } +.content-source-control { min-width: 0; } +.content-source-control .repository-picker-input, .content-source-url { min-height: 36px; background: var(--panel); border-radius: 9px; } +.content-source-control .repository-picker-input input, .content-source-url input { font-size: 12px; } +.content-source-url { display: flex; align-items: center; padding-left: 10px; border: 1px solid var(--border); transition: border-color .12s, box-shadow .12s; } +.content-source-url:focus-within { border-color: var(--accent); box-shadow: var(--ring); } +.content-source-url > svg { width: 15px; height: 15px; flex: 0 0 auto; fill: none; stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; } +.content-source-url input { width: 100%; min-width: 0; height: 34px; padding: 6px 9px; color: var(--text); background: transparent; border: 0; outline: 0; } +.content-source-url > button { width: 28px; height: 28px; margin-right: 4px; flex: 0 0 auto; color: var(--panel); background: var(--accent); border: 0; border-radius: 7px; font-size: 17px; line-height: 1; cursor: pointer; } +.content-source-url > button:disabled { opacity: .35; cursor: default; } +.content-source-list { display: flex; flex-wrap: wrap; gap: 6px; } +.content-source-list > span { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 5px 4px 8px; color: var(--muted); background: var(--panel); border: 1px solid var(--border-soft); border-radius: 8px; font-size: 10.5px; } +.content-source-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.content-source-list b { color: var(--accent-2); font-size: 8px; letter-spacing: .04em; text-transform: uppercase; } +.content-source-list button { display: grid; place-items: center; width: 19px; height: 19px; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; } +.content-source-list button:hover { color: var(--text); background: var(--hover-surface); } +.content-source-empty { margin: -2px 0 0; color: var(--muted-2); font-size: 10px; } +.content-source-error { color: var(--danger); font-size: 10.5px; } +.content-source-status { margin-top: -9px; color: var(--muted-2); font-size: 10px; } +.goal-proposals-start { display: flex; align-items: center; justify-content: center; gap: 11px; min-height: 92px; padding: 18px; color: var(--muted); border: 1px dashed var(--border); border-radius: 11px; background: color-mix(in srgb, var(--panel-2) 45%, transparent); text-align: left; } +.goal-proposals-start > span { display: grid; place-items: center; width: 36px; height: 36px; flex: 0 0 auto; color: var(--accent); background: var(--accent-faint); border-radius: 50%; } +.goal-proposals-start svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; } +.goal-proposals-start > div { display: grid; gap: 3px; } +.goal-proposals-start strong { color: var(--text); font-size: 12px; } +.goal-proposals-start small { font-size: 10.5px; line-height: 1.4; } +.goal-proposals-generate { min-width: 140px; } .goal-proposals-loading { display: flex; align-items: center; gap: 10px; padding: 26px 0; color: var(--muted); font-size: 13px; } .goal-proposals-spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: goalSpin .8s linear infinite; } @keyframes goalSpin { to { transform: rotate(360deg); } } @@ -176,6 +222,17 @@ .goal-proposal-content > :last-child { margin-bottom: 0; } .goal-proposal-content .task-list-item { flex-wrap: wrap; } .goal-proposal-content .task-list-item > ul, .goal-proposal-content .task-list-item > ol { flex-basis: 100%; margin-left: 22px; } +.goal-proposal-media { display: grid; gap: 8px; padding: 11px 14px 14px; border-top: 1px solid var(--border-soft); } +.goal-proposal-media > strong { color: var(--muted); font-size: 9.5px; letter-spacing: .06em; text-transform: uppercase; } +.goal-proposal-media > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 7px; } +.goal-proposal-media-card { overflow: hidden; background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 8px; } +.goal-proposal-media-card:hover { border-color: var(--accent-border); } +.goal-proposal-media-preview { display: block; width: 100%; height: 150px; background: var(--panel-3); object-fit: contain; } +.goal-proposal-media-preview img { display: block; width: 100%; height: 100%; object-fit: contain; } +.goal-proposal-media-info { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 3px 7px; padding: 9px; color: var(--text); text-decoration: none; border-top: 1px solid var(--border-soft); } +.goal-proposal-media-info > span { grid-row: 1 / 3; align-self: start; padding: 2px 5px; color: var(--accent); background: var(--accent-faint); border-radius: 4px; font-size: 8px; font-weight: 800; text-transform: uppercase; } +.goal-proposal-media-info > b { font-size: 11px; } +.goal-proposal-media-info > small { color: var(--muted); font-size: 10.5px; line-height: 1.4; } .goal-x-thread { display: grid; padding: 15px 18px 18px; } .goal-x-post { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 10px; } .goal-x-post-rail { display: grid; grid-template-rows: 30px 1fr; justify-items: center; } @@ -191,6 +248,8 @@ .goal-x-post-body > small { display: block; color: var(--muted-2); font-size: 9.5px; text-align: right; } .goal-x-post-body > small.over-limit { color: #f85149; font-weight: 700; } @media (max-width: 560px) { + .content-source-compose { grid-template-columns: 1fr; } + .content-source-tabs button { min-height: 30px; flex: 1; justify-content: center; } .goal-proposal-head { grid-template-columns: auto minmax(0, 1fr); } .goal-proposal-head > .goal-proposal-copy { grid-column: 1 / -1; justify-self: end; } .goal-x-thread { padding-inline: 12px; } diff --git a/src/types/goals.ts b/src/types/goals.ts index 07cefb6..22d4d7b 100644 --- a/src/types/goals.ts +++ b/src/types/goals.ts @@ -12,6 +12,18 @@ export const GOAL_METRICS: readonly GoalMetric[] = GOAL_METRIC_DEFINITIONS.map(( export const GOAL_PROPOSAL_FORMATS = ["x-thread", "linkedin-post", "mastodon-post", "post", "issue", "discussion", "email", "checklist", "message", "doc"] as const; export type GoalProposalFormat = (typeof GOAL_PROPOSAL_FORMATS)[number]; +export type GoalContentSource = + | { type: "repository"; value: string } + | { type: "website"; value: string }; + +export interface GoalMediaSuggestion { + kind: "image" | "video"; + title: string; + /** A concrete asset URL, or the source page where it can be found. */ + sourceUrl: string; + guidance: string; +} + /** A ready-to-use deliverable that carries out one recommended action. */ export interface GoalProposal { title: string; @@ -21,6 +33,8 @@ export interface GoalProposal { content: string; /** Complete, ordered X posts. Present when format is `x-thread`. */ threadPosts?: string[]; + /** Visual assets that can accompany this platform-specific draft. */ + mediaSuggestions?: GoalMediaSuggestion[]; } export interface GoalSuggestion { diff --git a/src/utils/socialProposals.ts b/src/utils/socialProposals.ts index dcd19db..6f41d6b 100644 --- a/src/utils/socialProposals.ts +++ b/src/utils/socialProposals.ts @@ -1,4 +1,5 @@ -import type { GoalProposal, GoalProposalFormat } from "../types/goals"; +import type { GoalContentSource, GoalMediaSuggestion, GoalProposal, GoalProposalFormat } from "../types/goals"; +import { parseRepositoryName } from "./repository"; export const SOCIAL_PROPOSAL_FORMATS = ["x-thread", "linkedin-post", "mastodon-post"] as const satisfies readonly GoalProposalFormat[]; @@ -17,6 +18,133 @@ function hashtagCount(text: string): number { return [...text.matchAll(/(?:^|\s)#[\p{L}\p{N}_]+/gu)].length; } +function normalizeHttpUrl(value: string): string | null { + try { + const url = new URL(value.trim()); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +/** Validates, canonicalizes, and limits user-provided campaign sources. */ +export function normalizeContentSources(entries: unknown, limit = 6): GoalContentSource[] { + if (!Array.isArray(entries)) return []; + const sources: GoalContentSource[] = []; + const seen = new Set(); + for (const raw of entries) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const type = entry.type; + let value: string | null = null; + if (type === "repository") { + const parsed = parseRepositoryName(String(entry.value ?? "").trim()); + value = parsed ? `${parsed[0]}/${parsed[1]}` : null; + } + if (type === "website") value = normalizeHttpUrl(String(entry.value ?? "")); + if (!value) continue; + const key = `${type}:${value.toLocaleLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + sources.push({ type, value } as GoalContentSource); + if (sources.length >= limit) break; + } + return sources; +} + +export function extractMediaUrls(markdown: string, baseUrl?: string | null): string[] { + const candidates = [ + ...[...markdown.matchAll(/!\[[^\]]*\]\((?:<)?([^\s)>]+)(?:>)?(?:\s+["'][^"']*["'])?\)/g)].map((match) => match[1]), + ...[...markdown.matchAll(/<(?:img|video|source)\b[^>]*?\bsrc=["']([^"']+)["']/gi)].map((match) => match[1]), + ...[...markdown.matchAll(/]*?property=["'](?:og:image|og:video|twitter:image)["'][^>]*?content=["']([^"']+)["']/gi)].map((match) => match[1]), + ...[...markdown.matchAll(/]*?content=["']([^"']+)["'][^>]*?property=["'](?:og:image|og:video|twitter:image)["']/gi)].map((match) => match[1]), + ...[...markdown.matchAll(/\[[^\]]+\]\(([^\s)]+\.(?:mp4|webm|mov|gif)(?:\?[^\s)]*)?)\)/gi)].map((match) => match[1]), + ]; + const urls: string[] = []; + for (const candidate of candidates) { + try { + const resolved = new URL(candidate, baseUrl ?? undefined); + if ((resolved.protocol === "http:" || resolved.protocol === "https:") && !urls.includes(resolved.toString())) urls.push(resolved.toString()); + } catch { /* Ignore malformed and unresolved relative links. */ } + } + return urls.slice(0, 12); +} + +export interface WebPageSignal { + title: string | null; + excerpt: string; + mediaUrls: string[]; +} + +/** Extracts readable text and concrete media from a bounded HTML response. */ +export function extractWebPageSignal(html: string, pageUrl: string, excerptLength = 7_000): WebPageSignal { + const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); + const decode = (value: string) => value + .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))) + .replace(/&#x([\da-f]+);/gi, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, "\"").replace(/'/gi, "'"); + const clean = (value: string) => decode(value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()); + const readable = html + .replace(/<(?:script|style|noscript|svg)\b[\s\S]*?<\/(?:script|style|noscript|svg)>/gi, " ") + .replace(//g, " "); + return { + title: titleMatch ? clean(titleMatch[1]) || null : null, + excerpt: clean(readable).slice(0, excerptLength), + mediaUrls: extractMediaUrls(html, pageUrl), + }; +} + +export function isVideoMediaUrl(value: string): boolean { + try { + return /\.(?:mp4|webm|mov|m4v)(?:$|\?)/i.test(new URL(value).pathname); + } catch { + return false; + } +} + +/** Ensures each post receives a concrete source asset, rotating the project library between posts. */ +export function attachSourceMedia(proposals: GoalProposal[], mediaUrls: string[]): GoalProposal[] { + const assets = [...new Set(mediaUrls.map(normalizeHttpUrl).filter((url): url is string => Boolean(url)))]; + if (!assets.length) return proposals.map((proposal) => ({ ...proposal, mediaSuggestions: [] })); + const allowed = new Set(assets); + return proposals.map((proposal, index) => { + const selected = (proposal.mediaSuggestions ?? []).filter((media) => allowed.has(media.sourceUrl)).slice(0, 2); + if (selected.length) return { ...proposal, mediaSuggestions: selected }; + const sourceUrl = assets[index % assets.length]; + const filename = decodeURIComponent(new URL(sourceUrl).pathname.split("/").pop() || "Source asset"); + return { + ...proposal, + mediaSuggestions: [{ + kind: isVideoMediaUrl(sourceUrl) ? "video" : "image", + title: filename, + sourceUrl, + guidance: "Attach this existing source asset to the post and verify reuse rights before publishing.", + }], + }; + }); +} + +function normalizeMediaSuggestions(value: unknown): GoalMediaSuggestion[] { + if (!Array.isArray(value)) return []; + const suggestions: GoalMediaSuggestion[] = []; + const seen = new Set(); + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const kind = entry.kind === "image" || entry.kind === "video" ? entry.kind : null; + const sourceUrl = normalizeHttpUrl(String(entry.sourceUrl ?? "")); + const title = String(entry.title ?? "").trim(); + const guidance = String(entry.guidance ?? "").trim(); + if (!kind || !sourceUrl || !title || !guidance || seen.has(sourceUrl)) continue; + seen.add(sourceUrl); + suggestions.push({ kind, sourceUrl, title, guidance }); + if (suggestions.length >= 3) break; + } + return suggestions; +} + export function socialProposalIssue(proposal: GoalProposal): string | null { if (!proposal.title.trim()) return "missing title"; if (!proposal.summary.trim()) return "missing audience and angle summary"; @@ -65,6 +193,7 @@ export function normalizeSocialProposals(entries: unknown): GoalProposal[] { summary: String(entry.summary ?? "").trim(), content, threadPosts: posts, + mediaSuggestions: normalizeMediaSuggestions(entry.mediaSuggestions), }; const fingerprint = content.toLocaleLowerCase(); if (seenFormats.has(format) || seenContent.has(fingerprint) || socialProposalIssue(proposal)) continue; diff --git a/tests/utils/socialProposals.test.ts b/tests/utils/socialProposals.test.ts index 713930b..fe77cc5 100644 --- a/tests/utils/socialProposals.test.ts +++ b/tests/utils/socialProposals.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { hasCompleteSocialSet, normalizeSocialProposals, socialCharacterCount } from "../../src/utils/socialProposals"; +import type { GoalProposal } from "../../src/types/goals"; +import { attachSourceMedia, extractMediaUrls, extractWebPageSignal, hasCompleteSocialSet, normalizeContentSources, normalizeSocialProposals, socialCharacterCount } from "../../src/utils/socialProposals"; describe("socialCharacterCount", () => { it("counts an emoji as one Unicode code point", () => { @@ -7,6 +8,57 @@ describe("socialCharacterCount", () => { }); }); +describe("normalizeContentSources", () => { + it("accepts repositories and HTTP websites while removing duplicates and unsafe URLs", () => { + expect(normalizeContentSources([ + { type: "repository", value: "owner/project" }, + { type: "repository", value: "owner/project" }, + { type: "website", value: "https://example.com/media#gallery" }, + { type: "website", value: "file:///etc/passwd" }, + ])).toEqual([ + { type: "repository", value: "owner/project" }, + { type: "website", value: "https://example.com/media" }, + ]); + }); +}); + +describe("extractMediaUrls", () => { + it("finds Markdown and HTML media and resolves relative URLs", () => { + expect(extractMediaUrls( + "![Demo](assets/demo.png)\n", + "https://raw.example/owner/repo/main/README.md", + )).toEqual([ + "https://raw.example/owner/repo/main/assets/demo.png", + "https://cdn.example/demo.mp4", + ]); + }); +}); + +describe("extractWebPageSignal", () => { + it("extracts readable source content and resolves page media", () => { + const result = extractWebPageSignal( + `Latest & greatest

Version 2

Faster builds.

`, + "https://example.com/releases/v2", + ); + expect(result.title).toBe("Latest & greatest"); + expect(result.excerpt).toContain("Version 2 Faster builds."); + expect(result.excerpt).not.toContain("ignore"); + expect(result.mediaUrls).toEqual(["https://example.com/cover.png"]); + }); +}); + +describe("attachSourceMedia", () => { + it("rotates concrete project assets between posts and removes invented media", () => { + const proposals = [ + { title: "X", format: "x-thread", summary: "s", content: "c", mediaSuggestions: [{ kind: "image", title: "Fake", sourceUrl: "https://fake.test/x.png", guidance: "g" }] }, + { title: "LinkedIn", format: "linkedin-post", summary: "s", content: "c" }, + ] as GoalProposal[]; + const result = attachSourceMedia(proposals, ["https://source.test/a.png", "https://source.test/demo.mp4"]); + expect(result[0].mediaSuggestions?.[0].sourceUrl).toBe("https://source.test/a.png"); + expect(result[1].mediaSuggestions?.[0]).toMatchObject({ kind: "video", sourceUrl: "https://source.test/demo.mp4" }); + }); +}); + describe("normalizeSocialProposals", () => { const threadPosts = ["Hook", "Problem", "Approach", "Evidence", "Call to action"]; @@ -14,10 +66,14 @@ describe("normalizeSocialProposals", () => { const result = normalizeSocialProposals([ { title: "X", format: "x-thread", summary: "Developers; README angle.", content: "wrong", threadPosts }, { title: "LinkedIn", format: "linkedin-post", summary: "Technical leaders; project value.", content: "A professional post.", threadPosts: [] }, - { title: "Mastodon", format: "mastodon-post", summary: "OSS community; contribution angle.", content: "A community post.", threadPosts: [] }, + { title: "Mastodon", format: "mastodon-post", summary: "OSS community; contribution angle.", content: "A community post.", threadPosts: [], mediaSuggestions: [ + { kind: "image", title: "Demo", sourceUrl: "https://example.com/demo.png", guidance: "Use the existing screenshot." }, + { kind: "audio", title: "Invalid", sourceUrl: "file:///demo", guidance: "No." }, + ] }, ]); expect(result).toHaveLength(3); + expect(result[2].mediaSuggestions).toEqual([{ kind: "image", title: "Demo", sourceUrl: "https://example.com/demo.png", guidance: "Use the existing screenshot." }]); expect(result[0].content).toBe(threadPosts.join("\n\n---\n\n")); expect(hasCompleteSocialSet(result)).toBe(true); }); From 6d513dc87998a065a2cdeee2f5228451392491db Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 09:00:13 +0200 Subject: [PATCH 03/54] chore(growth): establish governance baseline --- growth-studio-project/.gitignore | 1 + growth-studio-project/README.md | 89 ++++ growth-studio-project/docs/GS_DECISIONS.md | 15 + .../docs/GS_FEATURE_MATRIX.md | 46 ++ growth-studio-project/docs/GS_PLAN.md | 246 +++++++++ growth-studio-project/scripts/run-gs-tasks.sh | 480 ++++++++++++++++++ .../scripts/validate-gs-task.sh | 110 ++++ growth-studio-project/tasks/GS-000.md | 35 ++ growth-studio-project/tasks/GS-001.md | 17 + growth-studio-project/tasks/GS-002.md | 16 + growth-studio-project/tasks/GS-003.md | 16 + growth-studio-project/tasks/GS-010.md | 15 + growth-studio-project/tasks/GS-011.md | 17 + growth-studio-project/tasks/GS-012.md | 16 + growth-studio-project/tasks/GS-013.md | 15 + growth-studio-project/tasks/GS-014.md | 16 + growth-studio-project/tasks/GS-015.md | 14 + growth-studio-project/tasks/GS-016.md | 16 + growth-studio-project/tasks/GS-020.md | 16 + growth-studio-project/tasks/GS-021.md | 15 + growth-studio-project/tasks/GS-022.md | 15 + growth-studio-project/tasks/GS-023.md | 17 + growth-studio-project/tasks/GS-024.md | 15 + growth-studio-project/tasks/GS-025.md | 15 + growth-studio-project/tasks/GS-026.md | 15 + growth-studio-project/tasks/PROGRESS.md | 29 ++ src/utils/colors.ts | 2 +- tests/utils/colors.test.ts | 7 +- 28 files changed, 1322 insertions(+), 4 deletions(-) create mode 100644 growth-studio-project/.gitignore create mode 100644 growth-studio-project/README.md create mode 100644 growth-studio-project/docs/GS_DECISIONS.md create mode 100644 growth-studio-project/docs/GS_FEATURE_MATRIX.md create mode 100644 growth-studio-project/docs/GS_PLAN.md create mode 100755 growth-studio-project/scripts/run-gs-tasks.sh create mode 100755 growth-studio-project/scripts/validate-gs-task.sh create mode 100644 growth-studio-project/tasks/GS-000.md create mode 100644 growth-studio-project/tasks/GS-001.md create mode 100644 growth-studio-project/tasks/GS-002.md create mode 100644 growth-studio-project/tasks/GS-003.md create mode 100644 growth-studio-project/tasks/GS-010.md create mode 100644 growth-studio-project/tasks/GS-011.md create mode 100644 growth-studio-project/tasks/GS-012.md create mode 100644 growth-studio-project/tasks/GS-013.md create mode 100644 growth-studio-project/tasks/GS-014.md create mode 100644 growth-studio-project/tasks/GS-015.md create mode 100644 growth-studio-project/tasks/GS-016.md create mode 100644 growth-studio-project/tasks/GS-020.md create mode 100644 growth-studio-project/tasks/GS-021.md create mode 100644 growth-studio-project/tasks/GS-022.md create mode 100644 growth-studio-project/tasks/GS-023.md create mode 100644 growth-studio-project/tasks/GS-024.md create mode 100644 growth-studio-project/tasks/GS-025.md create mode 100644 growth-studio-project/tasks/GS-026.md create mode 100644 growth-studio-project/tasks/PROGRESS.md diff --git a/growth-studio-project/.gitignore b/growth-studio-project/.gitignore new file mode 100644 index 0000000..cc17739 --- /dev/null +++ b/growth-studio-project/.gitignore @@ -0,0 +1 @@ +.runtime/ diff --git a/growth-studio-project/README.md b/growth-studio-project/README.md new file mode 100644 index 0000000..40a6fa4 --- /dev/null +++ b/growth-studio-project/README.md @@ -0,0 +1,89 @@ +# Growth Studio project (ralph loop) + +This directory is the single tracked home for turning GitDeck's Growth Studio +into a first-class, self-contained mode of the application: a dedicated shell +at `/growth`, a repository-centric workspace, a real editorial calendar with +AI-generated, media-complete content, and a measure-and-learn loop. + +Hard constraints (never violated by any task): + +- **The rest of GitDeck keeps working unchanged.** Inbox, repositories, + issues, pull requests, insights, alerts, CI, digests, board and preferences + keep their routes, layout and behavior. Only the `goals` tab is replaced by + the Growth Studio entry. +- **Rules from `AGENTS.md` apply**: English identifiers, comments, docs and + commit messages; pure business logic in `src/utils` with mirrored tests + under `tests/utils`; tests never inside `src`; GitHub API access stays behind + server-side endpoints; no unrelated refactors mixed with feature work. +- **Both locales.** Every new UI string exists in `src/i18n/en.ts` and + `src/i18n/it.ts` (see `docs/translations.md`). +- **Local-first and copy-paste publishing.** No social network credentials are + stored; content is copied (text and image) by the user. Media is mandatory + for a post to reach the `ready` state. +- **Existing goals data is preserved.** Migrations are additive and idempotent. + +## Layout + +- `docs/GS_PLAN.md`: architecture, data model, AI pipeline, phases, standard task loop. +- `docs/GS_FEATURE_MATRIX.md`: authoritative feature inventory and status. +- `docs/GS_DECISIONS.md`: settled decisions with their rationale. +- `tasks/`: task specifications and the authoritative `PROGRESS.md` ledger. +- `scripts/run-gs-tasks.sh`: the ralph loop runner (headless pi or Claude Code sessions). +- `scripts/validate-gs-task.sh`: the local validation gate run after every task. +- `.runtime/`: ignored runner locks, state, logs and gate cache, created on first execution. + +## Run the loop + +```bash +git checkout -b feat/growth-studio # once, from the branch holding the goals work +growth-studio-project/scripts/run-gs-tasks.sh +``` + +With no task IDs, the runner reads `tasks/PROGRESS.md` and starts at the first +`PENDING` task, then continues in lexical order, re-scanning the ledger after +every task so tasks authored mid-run (by phase-opening tasks) are picked up. +Explicit task IDs are supported: + +```bash +growth-studio-project/scripts/run-gs-tasks.sh GS-010 GS-011 +``` + +Each task runs as one non-interactive agent session on the loop branch +(`feat/growth-studio` by default, override with `GS_LOOP_BRANCH`). After the +session the runner re-runs the validation gate itself; a failed gate starts a +focused repair session that amends the same task commit, up to +`GS_LOOP_REPAIR_ATTEMPTS` times (default 3). + +The gate runs `npm run typecheck`, `npm test` and `npm run build`, plus guards +for whitespace errors, tests placed under `src/`, and the presence of both +locale files. Successful stages are cached for six hours, keyed by a hash of +all relevant tracked and non-ignored inputs; any source, test, lockfile or +config change forces the checks to run again. Set `GS_VALIDATION_CACHE=0` to +force every check, or configure the lifetime with `GS_VALIDATION_CACHE_TTL`. + +## Telegram notifications + +```bash +export TELEGRAM_BOT_TOKEN=... +export TELEGRAM_CHAT_ID=... +``` + +Start, finish, repair and failure notifications carry the global completed-task +percentage from `tasks/PROGRESS.md`, prefixed with `[gitdeck-gs]`. Both +variables must be set together; dry runs never send external notifications. + +## Options + +``` +--agent AGENT CLI agent running the sessions: pi or claude (default: pi) +--model MODEL Model override for the selected agent +--thinking LEVEL Thinking level, pi agent only (default: high) +--live / --no-live Stream agent activity to the terminal (default: auto by TTY) +--no-notify Disable desktop notifications +--dry-run Print the sessions that would run +--force Run tasks even when PROGRESS.md says COMPLETED +``` + +Defaults can also be set via `GS_LOOP_AGENT`, `GS_LOOP_MODEL`, +`GS_LOOP_THINKING`, `GS_LOOP_BRANCH`, `GS_LOOP_LIVE`, `GS_LOOP_REPAIR_ATTEMPTS`, +`GS_LOOP_MAX_TASKS`, `GS_VALIDATION_CACHE` and `GS_VALIDATION_CACHE_TTL`. diff --git a/growth-studio-project/docs/GS_DECISIONS.md b/growth-studio-project/docs/GS_DECISIONS.md new file mode 100644 index 0000000..63f4cd2 --- /dev/null +++ b/growth-studio-project/docs/GS_DECISIONS.md @@ -0,0 +1,15 @@ +# Growth Studio decisions + +Settled decisions. Tasks append new rows when they must settle something the +plan leaves open; they never reopen an existing row. + +| ID | Date | Decision | Rationale | +|---|---|---|---| +| D-001 | 2026-09-04 | The repository is the Growth Studio entry point; goals are the Missions panel of a repository workspace. Content items link to a repository and optionally to one or more goals. | A release thread serves stars, forks and downloads at once; a plan makes sense without a numeric target; the unified calendar needs repository-level ownership of content. | +| D-002 | 2026-09-04 | Growth Studio is a separate shell at `/growth`, opened from the main menu with `target="_blank"` and `rel="noopener"`. It has its own top bar and sidebar and renders no dashboard filters, tab strip or footer. | The user wants a focused tool that hides unrelated parts and can sit next to the dashboard in another window. | +| D-003 | 2026-09-04 | Publishing is copy-paste. Every post reaching `ready` must carry at least one media attachment; the UI offers copy text, copy image to clipboard and download image. The application never posts to a network. | No credentials to store, no platform approvals; media-complete posts are the value the user asked for. | +| D-004 | 2026-09-04 | A unified calendar across repositories is in scope, with a colour per repository and multi-repository deconfliction at plan time. | Maintainers of several projects plan as one person. | +| D-005 | 2026-09-04 | Generated image cards are SVG templates produced by the server and rasterized in the browser on a canvas. No native image dependency is added. | Keeps the Docker image and install small and avoids platform-specific builds. | +| D-006 | 2026-09-04 | The task loop mirrors the Emailchef `web-ui-ng-project` runner: `GS-NNN` tasks, `PROGRESS.md` ledger, gate, repair sessions, Telegram notifications. Default agent is pi. Commits carry no `Co-Authored-By` trailer. | Proven structure; pi is the preferred agent; user preference on trailers. | +| D-007 | 2026-09-04 | Interventions and content items become first-class SQLite rows; the legacy JSON in `repository_goals.suggestions` is migrated once and no longer written. | Statuses, dates and attribution need rows, not blobs. | +| D-008 | 2026-09-04 | Plan generation separates slots (deterministic, from cadence and pillars) from angles (AI, one call per plan) from drafts (AI, on demand or batch). | Cheap to regenerate, reorderable before writing, testable without AI. | diff --git a/growth-studio-project/docs/GS_FEATURE_MATRIX.md b/growth-studio-project/docs/GS_FEATURE_MATRIX.md new file mode 100644 index 0000000..c400e98 --- /dev/null +++ b/growth-studio-project/docs/GS_FEATURE_MATRIX.md @@ -0,0 +1,46 @@ +# Growth Studio feature matrix + +Authoritative inventory of Growth Studio capabilities. Statuses: `EXISTING` +(present before the project, may move), `PLANNED`, `IN_PROGRESS`, `DONE`, +`DROPPED`. Tasks update the rows they touch. + +| Feature | Location (current or target) | Status | Task | +|---|---|---|---| +| Governance baseline and validation gate | `growth-studio-project/scripts/`, `src/utils/colors.ts`, `tests/utils/colors.test.ts` | DONE | GS-000 | +| Goals CRUD with metric refresh (stars, forks, closed PRs, downloads) | `GoalsView.tsx`, `server/goals.ts`, `routes/goals.ts` | EXISTING | — | +| AI suggestions per goal (3–5 actions) with deterministic fallback | `server/goals.ts` `generateGoalSuggestions` | EXISTING | GS-021 migrates | +| AI proposals per suggestion (thread, LinkedIn, Mastodon, issue, doc…) with media suggestions | `server/goals.ts` `generateGoalProposals`, `GoalProposalsModal.tsx` | EXISTING | GS-021 migrates | +| Repository content sources (repositories and websites) with SSRF guards | `goalStore.ts`, `routes/repository.ts`, `RepositoryContentSources.tsx` | EXISTING | GS-025 moves to Library | +| AI provider settings and connection test | `preferences/AiIntegrationSettings.tsx`, `server/ai/*` | EXISTING | — | +| `/growth` client routes served by the SPA | `server/spa.ts`, `main.tsx` | PLANNED | GS-010 | +| Growth shell: own top bar, sidebar, `mode-growth` body class, no dashboard chrome | `components/growth/GrowthStudioApp.tsx`, `styles/growth/shell.css` | PLANNED | GS-011 | +| Main-menu entry opening `/growth` in a new window; `goals` tab removed; `/goals` redirect | `App.tsx` | PLANNED | GS-012 | +| Missions panel hosting the existing goals UI | `/growth/r/:owner/:repo/missions` | PLANNED | GS-013 | +| Growth home: repositories with profiles or goals, quick stats | `/growth` | PLANNED | GS-014 | +| Workspace overview per repository | `/growth/r/:owner/:repo` | PLANNED | GS-015 | +| Shell i18n, responsive layout, theme parity | shell components | PLANNED | GS-016 | +| Growth store schema (profiles, interventions, plans, items, assets, performance) | `server/growth/store.ts` | PLANNED | GS-020 | +| One-shot migration of legacy suggestions and proposals | `server/growth/store.ts` | PLANNED | GS-021 | +| Growth API routes | `server/routes/growth.ts`, `api/growth.ts` | PLANNED | GS-022 | +| Interventions backlog with statuses and manual creation | `/growth/r/:owner/:repo/interventions` | PLANNED | GS-023 | +| Content items list and drawer with copy actions | Interventions and Calendar panels | PLANNED | GS-024 | +| Library panel: sources, profile (voice, audience, channels), pillars, cadence | `/growth/r/:owner/:repo/library` | PLANNED | GS-025 | +| Deterministic slot builder from cadence, pillars, posting windows | `utils/growth/planSlots.ts` | PLANNED | Phase 3 | +| AI planner assigning angles to slots | `server/growth/planner.ts` | PLANNED | Phase 3 | +| AI drafter producing media-aware drafts per slot | `server/growth/drafter.ts` | PLANNED | Phase 3 | +| Calendar month and week views with drag and drop | `components/growth/calendar/` | PLANNED | Phase 3 | +| Queue "this week" with copy and mark published | Calendar panel | PLANNED | Phase 3 | +| ICS export | `/api/growth/calendar.ics` | PLANNED | Phase 3 | +| Assets library with uploads and imports from README and web sources | `server/growth/assets.ts` | PLANNED | Phase 4 | +| Generated SVG cards (release, milestone, stats, quote, what's new) | `server/growth/cards.ts` | PLANNED | Phase 4 | +| Client rasterization, copy image to clipboard, download | `utils/growth/rasterize.ts` | PLANNED | Phase 4 | +| Media gate: `ready` requires media | store and UI | PLANNED | Phase 4 | +| Opportunity rules producing interventions | `utils/growth/opportunityRules.ts`, `server/growth/rules.ts` | PLANNED | Phase 5 | +| Attribution of published items to metric deltas (48h, 7d) | `server/growth/attribution.ts` | PLANNED | Phase 5 | +| Weekly Growth Review | `server/growth/review.ts`, `/growth/review` | PLANNED | Phase 5 | +| Evergreen recycling | planner and rules | PLANNED | Phase 5 | +| Plan re-weighting from performance | planner | PLANNED | Phase 5 | +| Unified calendar with per-repository colours and filters | `/growth/calendar` | PLANNED | Phase 6 | +| Multi-repository deconfliction | planner | PLANNED | Phase 6 | +| Growth settings (defaults, timezone) | `/growth/settings` | PLANNED | Phase 6 | +| README, CHANGELOG and screenshots | `README.md`, `CHANGELOG.md` | PLANNED | Phase 6 | diff --git a/growth-studio-project/docs/GS_PLAN.md b/growth-studio-project/docs/GS_PLAN.md new file mode 100644 index 0000000..7128ef4 --- /dev/null +++ b/growth-studio-project/docs/GS_PLAN.md @@ -0,0 +1,246 @@ +# Growth Studio plan + +Authoritative architecture and phase plan for the Growth Studio project. +Read it in full before every task. Sections marked *settled* are decisions +recorded in `GS_DECISIONS.md`; do not reopen them inside a task. + +## 1. Goal + +Turn Growth Studio from a panel inside the Goals tab into the central growth +tool of GitDeck: + +1. A **dedicated shell** at `/growth`, opened from the main menu in a new + window, that hides everything unrelated (dashboard filters sidebar, tab + strip, footer) and has its own navigation. +2. A **repository-centric workspace**: the repository is the entry point; + goals ("missions"), interventions, calendar, library and review are panels + of that workspace. +3. **Interventions as an editorial plan**: interventions and content items are + first-class persisted entities with status and dates; the AI fills a + calendar built from content pillars and per-channel cadence, then drafts + each slot with mandatory media. +4. A **measure-and-learn loop**: published content is attributed to metric + deltas, a weekly Growth Review reports what worked, and the next plan is + re-weighted accordingly. +5. A **unified calendar** across every repository with a growth profile. + +## 2. Hard constraints + +- Everything outside Growth Studio keeps its routes, layout and behavior. The + only change to the main application chrome is replacing the `goals` tab with + the Growth Studio link (`target="_blank"`, `rel="noopener"`). +- `AGENTS.md` rules: English everywhere; pure logic in `src/utils` with + mirrored tests in `tests/utils`; server logic tests in `tests/server`; no + tests under `src`; forge API access only through server endpoints; no + unrelated refactors; TypeScript for new files. +- Both `src/i18n/en.ts` and `src/i18n/it.ts` receive every new key. +- Publishing is copy-paste only. No social credentials, no outbound posting. +- Media is mandatory: a content item cannot become `ready` without at least one + media attachment. +- Schema changes are additive and idempotent (`CREATE TABLE IF NOT EXISTS`, + `ALTER TABLE ... ADD COLUMN` guarded by a `PRAGMA table_info` check). Existing + `repository_goals` and `repository_content_sources` rows keep working. +- New native dependencies are not allowed. Image rasterization happens in the + browser (SVG drawn on a canvas), not on the server. +- Each task ends with `growth-studio-project/scripts/validate-gs-task.sh` + printing `VALIDATION OK`, one conventional commit (scope `growth`), no push, + no `Co-Authored-By` trailer. + +## 3. Settled decisions (see GS_DECISIONS.md) + +- D-001 Repository is the entry point; goals are a panel. +- D-002 Growth Studio opens in a separate window from the main menu. +- D-003 Copy-paste publishing with mandatory media; images are copied to the + clipboard or downloaded, never posted by the app. +- D-004 Unified multi-repository calendar is in scope. +- D-005 Client-side rasterization for generated image cards. +- D-006 The loop runs with pi by default; Telegram notifications reuse the + Emailchef runner variables. + +## 4. Current state (inventory, 2026-09-04) + +Files a task will most often touch or extend: + +| Area | Files | Notes | +|---|---|---| +| Types | `src/types/goals.ts` | `RepositoryGoal`, `GoalSuggestion` (the current "intervention"), `GoalProposal` (the current draft), `GoalContentSource`, metric definitions | +| Server store | `src/server/goalStore.ts` | SQLite tables `repository_goals` (suggestions and proposals stored as JSON in `suggestions`) and `repository_content_sources` | +| Server logic | `src/server/goals.ts` | metric resolvers, `generateGoalSuggestions`, `generateGoalProposals`, README/release/website signal fetching with SSRF guards, `SOCIAL_PROPOSALS_VERSION` | +| Routes | `src/server/routes/goals.ts`, `src/server/routes/repository.ts` (content sources), `src/server/routes/index.ts` | `/api/goals*` | +| AI | `src/server/ai/client.ts` (`generateStructured`, `AiNotConfiguredError`, `AiRequestError`), `src/server/ai/settings.ts` (`isAiConfigured`), `src/server/aiDigest.ts` | provider-agnostic structured JSON generation | +| Data helpers | `src/server/dashboardData.ts` (cached repos, issues, PRs), `src/server/githubClient.ts` (`restApi`, `restApiPaginate`, `ghApiJson`), `src/server/snapshots.ts` (daily stars and forks history, 90 days), `src/server/digests.ts` | reuse for signals and attribution | +| Persistence helpers | `src/server/sqlite.ts` (`getDatabase`, `run`, `get`, `all`), `src/server/preferenceStore.ts` (JSON preferences by scope and key) | | +| SPA | `src/server/spa.ts` (`APP_ROUTES`, `isClientRoutePath`), `src/main.tsx` (`BrowserRouter`), `src/App.tsx` (`Tab`, `TAB_ROUTES`, `tabs`, body classes `tab-*`) | | +| UI | `src/components/views/GoalsView.tsx`, `src/components/views/GoalsLoadingState.tsx`, `src/components/modals/GoalProposalsModal.tsx`, `src/components/common/RepositoryPicker.tsx`, `RepositoryContentSources.tsx`, `ContentSourcePicker.tsx`, `src/components/preferences/AiIntegrationSettings.tsx` | | +| Client API | `src/api/github.ts` (`fetchGoals`, `createGoal`, `deleteGoal`, `generateGoalAdvice`, `fetchGoalProposals`, content sources) | | +| Pure utils and tests | `src/utils/goals.ts`, `src/utils/socialProposals.ts`; `tests/utils/goals.test.ts`, `tests/utils/socialProposals.test.ts`, `tests/server/aiClient.test.ts`, `tests/server/aiSettings.test.ts` | | +| Styles | `src/styles/goals.css`, `src/styles/layout-sidebar.css`, `src/styles/navigation.css`, `src/styles/tokens.css` | | +| Scripts | `package.json`: `dev`, `build`, `test` (vitest run), `typecheck` | | + +## 5. Target architecture + +### 5.1 Shell and routing + +- `src/main.tsx` mounts `GrowthStudioApp` when `location.pathname` starts with + `/growth`, otherwise `App`. Both share the providers (i18n, accounts). + Authentication reuses the existing auth state and `AuthGate`. +- `src/server/spa.ts` treats every `/growth` and `/growth/...` path as a client + route. `/goals` stays in `APP_ROUTES` and the client redirects it to + `/growth`. +- Routes: + - `/growth` — home: repositories with a growth profile or active goals, quick + stats, "open workspace", "unified calendar". + - `/growth/calendar` — unified calendar (all repositories). + - `/growth/review` — latest Growth Review across repositories. + - `/growth/r/:owner/:repo` — workspace overview. + - `/growth/r/:owner/:repo/missions|interventions|calendar|library|review`. + - `/growth/settings` — growth-wide preferences (default cadence, default + pillars, timezone), linking to the existing AI preferences page. +- Components live under `src/components/growth/` (shell, sidebar, top bar, + panels) and `src/components/growth/calendar/`. Styles under + `src/styles/growth/*.css`, imported from `src/styles.css`. Body class + `mode-growth` is set by the shell; dashboard styles must not leak into it. +- The main application replaces the `goals` tab with an anchor styled as a tab + that opens `/growth` in a new window. The `Tab` union loses `goals`; the + `GoalsView` component moves into the Missions panel of the workspace. + +### 5.2 Data model (SQLite, `src/server/growth/store.ts`) + +All tables carry `account_id` and use the same account scoping as goals. + +- `growth_profiles` (PK `account_id, repository`): `language`, `voice`, + `audience`, `channels` JSON (`x`, `linkedin`, `mastodon`, `bluesky`, + `discussion`, `blog`), `cadence` JSON (`{ channel: postsPerWeek }`), + `pillars` JSON (`[{ id, label, weight, description }]`), `hashtags` JSON, + `avoid` TEXT, `timezone`, `posting_windows` JSON (`[{ weekday, hour }]`), + `color` (calendar color), `updated_at`. +- `growth_interventions`: `id`, `account_id`, `repository`, `goal_id` NULL, + `category` (`product|community|engineering|marketing`), `title`, `action`, + `origin` (`ai|rule|manual`), `rule_key` NULL, `dedupe_key`, `status` + (`proposed|accepted|dismissed|done`), `created_at`, `updated_at`. +- `content_plans`: `id`, `account_id`, `repository`, `period_start`, + `period_end`, `cadence` JSON snapshot, `pillars` JSON snapshot, `status` + (`draft|active|archived`), `generated_at`, `created_at`. +- `content_items`: `id`, `account_id`, `repository`, `plan_id` NULL, + `intervention_id` NULL, `goal_ids` JSON, `channel`, `format` (existing + `GoalProposalFormat` values), `pillar`, `angle` (one-line brief), `title`, + `summary`, `body` (markdown), `thread_posts` JSON, `media` JSON + (`[{ assetId?, url?, kind, alt, caption? }]`), `sources` JSON (URLs cited), + `status` (`idea|draft|ready|scheduled|published|skipped`), `scheduled_for` + (ISO datetime, NULL for backlog), `published_at`, `published_url`, + `generated_at`, `generation_version`, `evergreen` INTEGER (0/1), + `created_at`, `updated_at`. +- `growth_assets`: `id`, `account_id`, `repository`, `kind` (`image|video`), + `origin` (`upload|readme|website|generated`), `path` (relative to + `DATA_DIR/growth-assets/`) or `url`, `title`, `alt`, `width`, `height`, + `card_template` NULL, `card_data` JSON NULL, `created_at`. +- `content_performance`: `content_id`, `window` (`48h|7d`), `measured_at`, + `metrics` JSON (`{ starsDelta, forksDelta, ... }`), PK (`content_id`, + `window`). + +Migration (one shot, idempotent, at first store access): every +`GoalSuggestion` in `repository_goals.suggestions` becomes a +`growth_interventions` row with `origin='ai'`, `status='proposed'`, +`goal_id` set, and each `GoalProposal` a `content_items` row with +`status='draft'` linked to that intervention. The JSON column is left in place +and no longer written. A `preferences` row (`growth`, `migratedSuggestionsV1`) +records completion. + +### 5.3 Server modules (`src/server/growth/`) + +- `store.ts` — schema, CRUD, migration. +- `signals.ts` — collects repository signals for AI prompts: repo metadata, + open issues and PRs, releases, README excerpt and media URLs, content + sources (move the fetchers out of `src/server/goals.ts`; keep the SSRF + guards), recent commits (`/repos/:repo/commits?per_page=20`), star history + from snapshots, goals with progress. +- `planner.ts` — builds a plan: deterministic slots from + `src/utils/growth/planSlots.ts`, then one `generateStructured` call assigns + an angle, pillar confirmation, sources and CTA to each slot; slots become + `content_items` with `status='idea'`. +- `drafter.ts` — turns one idea into a draft (body, thread posts, media + candidates from assets and signal media, alt text) with the platform rules + already in `src/utils/socialProposals.ts`. +- `rules.ts` + `src/utils/growth/opportunityRules.ts` — deterministic + opportunity detection producing interventions with `origin='rule'`: + release without a post within 3 days; star milestone within 5 percent; + unanswered good-first-issues older than 14 days; large PR merged without a + post; goal overdue risk (pace below required); evergreen content older than + 60 days eligible for recycling. +- `attribution.ts` — computes `content_performance` from snapshots at 48h and + 7d after `published_at`; aggregates per pillar and channel. +- `review.ts` — weekly Growth Review: published items, deltas, misses, + upcoming week, three proposed interventions; AI narrative optional. +- `cards.ts` — SVG templates for generated cards (release, milestone, stats, + quote, "what's new"); returns SVG strings, the browser rasterizes. +- Routes in `src/server/routes/growth.ts`, prefix `/api/growth/`. + +### 5.4 Client + +- `src/api/growth.ts` — typed fetchers for all growth endpoints. +- Panels: Home, Workspace overview, Missions (existing goals UI), Interventions + (backlog with filters and status actions), Calendar (month and week, drag + and drop reschedule, item drawer), Queue ("this week" list with copy text, + copy or download image, mark published), Library (sources, assets, profile, + pillars and cadence), Review. +- Media copy: `navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])` + with a download fallback. SVG cards are rendered to a canvas at 2x. +- ICS export endpoint `/api/growth/calendar.ics` for scheduled items. + +### 5.5 Unified calendar + +Same Calendar component without a repository filter: colour per repository +(`growth_profiles.color`), repository chip on each item, filters by +repository, channel, pillar and status. Multi-repository plan generation +staggers release-type items so two repositories never publish the same pillar +on the same day when avoidable. + +## 6. Phases and task numbering + +Task IDs are `GS-NNN`. Tens group phases. The last task of a phase authors the +next phase's task files and `PENDING` ledger rows when they do not exist yet. + +| Phase | Tasks | Scope | +|---|---|---| +| 0 Governance | GS-000..GS-003 | baseline, inventory verification, decisions, gate baseline | +| 1 Shell | GS-010..GS-016 | `/growth` routing, shell chrome, main-menu link, `/goals` redirect, Missions panel, home and workspace overview | +| 2 Data model | GS-020..GS-026 | growth store and migration, API routes, interventions backlog UI, content items UI, profile and library panel, phase 3 authoring | +| 3 Editorial plan | GS-030..GS-03x | pillars and cadence UI, slot builder, AI planner, drafter, calendar views, queue, mark published, ICS | +| 4 Media | GS-040..GS-04x | assets library, imports from README and web sources, card templates, client rasterization and clipboard copy, media gate for `ready` | +| 5 Loop | GS-050..GS-05x | opportunity rules, attribution, Growth Review, evergreen recycling, plan re-weighting | +| 6 Unified and release | GS-060..GS-06x | unified calendar, multi-repository deconfliction, growth settings, README and CHANGELOG, final QA | + +## 7. Standard task loop (every session) + +1. Read the task file, this plan, `GS_FEATURE_MATRIX.md`, `GS_DECISIONS.md` + and `PROGRESS.md`. +2. Inspect `git status`; preserve unrelated changes, never discard user work. +3. Set the task to `IN_PROGRESS` in `PROGRESS.md`. +4. Study the existing code listed in section 4 for the touched area before + writing new code. Extend existing helpers instead of duplicating them. +5. Implement the smallest complete change for the task scope only. Add keys to + both locale files. Put pure logic in `src/utils` with tests in `tests/utils`. +6. Run the validation gate: `growth-studio-project/scripts/validate-gs-task.sh`. + It must end with `VALIDATION OK`. +7. Update `GS_FEATURE_MATRIX.md` rows touched by the task, and + `GS_DECISIONS.md` when a task had to settle something new. +8. Review `git diff` and `git diff --check`. +9. Set the task to `COMPLETED` in `PROGRESS.md` with a one-line summary, + verification evidence, and ISO date. Never use `|` inside fields. +10. Create the task's single conventional commit with scope `growth`, e.g. + `feat(growth): add the /growth shell and navigation`. No push. No + `Co-Authored-By` trailer. +11. If blocked, set the task to `BLOCKED` with the blocker, leave the repo in a + safe state, and stop without claiming completion. + +## 8. Quality bar + +- Every server endpoint validates input and scopes by account, like + `src/server/routes/goals.ts`. +- Every fetch of a user-supplied URL goes through the SSRF guards in + `signals.ts`. +- AI calls use `generateStructured` with a JSON schema and a deterministic + fallback when AI is not configured, like `fallbackSuggestions` today. +- UI states: loading, empty, error and "AI not configured" for every panel. +- Keyboard: Escape closes drawers and modals; calendar items are focusable. +- Dark and light themes via existing tokens in `src/styles/tokens.css`. diff --git a/growth-studio-project/scripts/run-gs-tasks.sh b/growth-studio-project/scripts/run-gs-tasks.sh new file mode 100755 index 0000000..261b76f --- /dev/null +++ b/growth-studio-project/scripts/run-gs-tasks.sh @@ -0,0 +1,480 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || true)" +PROJECT_DIR="${ROOT_DIR}/growth-studio-project" +PLAN_FILE="${PROJECT_DIR}/docs/GS_PLAN.md" +MATRIX_FILE="${PROJECT_DIR}/docs/GS_FEATURE_MATRIX.md" +DECISIONS_FILE="${PROJECT_DIR}/docs/GS_DECISIONS.md" +TASK_DIR="${PROJECT_DIR}/tasks" +PROGRESS_FILE="${TASK_DIR}/PROGRESS.md" +GATE="${PROJECT_DIR}/scripts/validate-gs-task.sh" +GATE_REL="growth-studio-project/scripts/validate-gs-task.sh" +RUN_DIR="${PROJECT_DIR}/.runtime" +LOG_DIR="${RUN_DIR}/logs" +STATE_FILE="${RUN_DIR}/state.tsv" +LOCK_DIR="${RUN_DIR}/lock" +BRANCH="${GS_LOOP_BRANCH:-feat/growth-studio}" +AGENT="${GS_LOOP_AGENT:-pi}" +MODEL="${GS_LOOP_MODEL:-}" +THINKING="${GS_LOOP_THINKING:-high}" # pi agent only +REPAIR_ATTEMPTS="${GS_LOOP_REPAIR_ATTEMPTS:-3}" +MAX_TASKS="${GS_LOOP_MAX_TASKS:-0}" # 0 = run until no PENDING task remains +TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" +TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}" +TELEGRAM_PREFIX="[gitdeck-gs]" +NOTIFY=1 +DRY_RUN=0 +FORCE=0 +LIVE="${GS_LOOP_LIVE:-}" # empty = auto (enabled when stdout is a TTY) +TASKS=() + +usage() { + cat <<'USAGE' +Usage: growth-studio-project/scripts/run-gs-tasks.sh [options] [GS-NNN ...] + +Runs one non-interactive agent session per task, sequentially, on the loop +branch (feat/growth-studio by default, override with GS_LOOP_BRANCH). With no +task IDs the runner re-scans growth-studio-project/tasks/PROGRESS.md after +every task and picks the first PENDING one, so tasks authored mid-run (by +phase-closing tasks) are picked up automatically. The runner stops at the +first failed or unvalidated task. Runtime logs and state live under +growth-studio-project/.runtime/ (ignored by Git). + +After each session the runner re-runs the validation gate itself +(scripts/validate-gs-task.sh). A failed gate starts a focused repair session +that amends the same task commit, up to GS_LOOP_REPAIR_ATTEMPTS times +(default 3). + +Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID to receive a Telegram message for +each task state change. Both must be set together. Dry runs do not send +external notifications. + +Options: + --agent AGENT CLI agent: pi or claude (default: pi) + --model MODEL Model override for the selected agent + --thinking LEVEL Thinking level, pi agent only (default: high) + --live Stream the agent's activity live to the terminal + (default: auto — enabled when stdout is a TTY). With the + claude agent this uses stream-json output rendered via jq; + pi output is always streamed. + --no-live Disable live streaming (plain final-output mode) + --no-notify Disable desktop notifications + --dry-run Print the sessions that would run + --force Run tasks even when PROGRESS.md says COMPLETED + -h, --help Show this help + +Environment: + GS_LOOP_AGENT Default for --agent (pi or claude) + GS_LOOP_MODEL Default for --model + GS_LOOP_THINKING Default for --thinking (pi only) + GS_LOOP_BRANCH Loop branch (default: feat/growth-studio) + GS_LOOP_LIVE Default for --live: 1/0 (unset = auto by TTY) + GS_LOOP_REPAIR_ATTEMPTS Maximum repair sessions per task (default: 3) + GS_LOOP_MAX_TASKS Stop after N tasks in auto mode (default: 0 = all) + GS_VALIDATION_CACHE Reuse successful checks for unchanged inputs (default: 1) + GS_VALIDATION_CACHE_TTL Cache lifetime in seconds (default: 21600; 0 disables hits) +USAGE +} + +iso_now() { + date +%Y-%m-%dT%H:%M:%S%z +} + +notify() { + local title="$1" message="$2" + printf '\a[%s] %s: %s\n' "$(iso_now)" "$title" "$message" + if (( NOTIFY )); then + if command -v notify-send >/dev/null 2>&1; then + notify-send "$title" "$message" >/dev/null 2>&1 || true + elif command -v osascript >/dev/null 2>&1; then + osascript \ + -e 'on run argv' \ + -e 'display notification (item 2 of argv) with title (item 1 of argv)' \ + -e 'end run' \ + "$title" "$message" >/dev/null 2>&1 || true + fi + fi +} + +notify_telegram() { + local title="$1" message="$2" + [[ -n "$TELEGRAM_BOT_TOKEN" ]] || return 0 + if ! curl --silent --show-error --fail \ + --connect-timeout 5 --max-time 15 --retry 2 \ + --request POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${TELEGRAM_PREFIX} ${title}: ${message}" \ + >/dev/null; then + echo "Warning: failed to send Telegram notification." >&2 + fi +} + +record_task_state() { + local task="$1" state="$2" reference="$3" title="$4" message="$5" + printf '%s\t%s\t%s\t%s\n' \ + "$(iso_now)" "$task" "$state" "$reference" >> "$STATE_FILE" + notify "$title" "$message" + if (( ! DRY_RUN )); then + notify_telegram "$title" "$message" + fi +} + +completion_progress() { + awk -F '|' ' + function trim(v) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); return v } + { + task = trim($2); status = trim($3) + if (task ~ /^GS-[0-9][0-9][0-9]$/) { + total++ + if (status == "COMPLETED") completed++ + } + } + END { + percentage = total == 0 ? 0 : completed * 100 / total + printf "%.1f%% (%d/%d)", percentage, completed, total + } + ' "$PROGRESS_FILE" +} + +progress_field() { + local task="$1" column="$2" + awk -F '|' -v task="$task" -v column="$column" ' + function trim(v) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); return v } + trim($2) == task { print trim($column) } + ' "$PROGRESS_FILE" +} + +first_pending_task() { + while IFS= read -r task_file; do + local task + task="$(basename "$task_file" .md)" + if [[ "$(progress_field "$task" 3)" == "PENDING" ]]; then + printf '%s\n' "$task" + return 0 + fi + done < <(find "$TASK_DIR" -maxdepth 1 -type f -name 'GS-[0-9][0-9][0-9].md' | sort) + return 1 +} + +validate_progress_entry() { + local task="$1" + local row_count status summary verification updated + + row_count="$(awk -F '|' -v task="$task" ' + function trim(v) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); return v } + trim($2) == task { count++ } + END { print count + 0 } + ' "$PROGRESS_FILE")" + [[ "$row_count" == "1" ]] || { + echo "PROGRESS.md must contain exactly one row for $task." >&2 + return 1 + } + + status="$(progress_field "$task" 3)" + summary="$(progress_field "$task" 4)" + verification="$(progress_field "$task" 5)" + updated="$(progress_field "$task" 6)" + + [[ "$status" == "COMPLETED" ]] || { + echo "$task is not COMPLETED in PROGRESS.md (status: $status)." >&2 + return 1 + } + [[ -n "$summary" && "$summary" != "—" ]] || { + echo "$task has no completion summary in PROGRESS.md." >&2 + return 1 + } + [[ -n "$verification" && "$verification" != "—" ]] || { + echo "$task has no verification evidence in PROGRESS.md." >&2 + return 1 + } + [[ "$updated" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { + echo "$task has no valid ISO completion date in PROGRESS.md." >&2 + return 1 + } + if ! git diff --quiet -- "$PROGRESS_FILE" || \ + ! git diff --cached --quiet -- "$PROGRESS_FILE"; then + echo "PROGRESS.md has uncommitted changes; the $task record must be committed." >&2 + return 1 + fi +} + +# Render claude stream-json events as readable live output: assistant text, +# tool invocations and the final result. Non-JSON lines pass through untouched. +format_claude_stream() { + jq -Rr --unbuffered ' + . as $raw | (try fromjson catch null) as $ev | + if ($ev | type) != "object" then $raw + elif $ev.type == "assistant" then + ([$ev.message.content[]? | + if .type == "text" then .text + elif .type == "tool_use" then + "→ " + .name + " " + ((.input | tojson) | .[0:200]) + else empty end + ] | join("\n") | select(length > 0)) + elif $ev.type == "result" then + "\n■ session " + ($ev.subtype // "done") + + (if $ev.total_cost_usd then + " (cost: $" + ($ev.total_cost_usd | tostring | .[0:6]) + ")" + else "" end) + else empty end' +} + +run_agent() { + # $1 = session name, $2 = prompt, $3 = log file, $4.. = context files (pi only) + local name="$1" prompt="$2" log_file="$3" status + shift 3 + local cmd=() + + if [[ "$AGENT" == "pi" ]]; then + cmd=(pi --print --approve --name "$name" --thinking "$THINKING") + [[ -z "$MODEL" ]] || cmd+=(--model "$MODEL") + local ctx + for ctx in "$@"; do + cmd+=("@${ctx}") + done + cmd+=("$prompt") + set +e + env -u PI_SESSION_ID -u PI_SESSION_FILE -u PI_PROVIDER -u PI_MODEL \ + -u PI_REASONING_LEVEL "${cmd[@]}" 2>&1 | tee -a "$log_file" + status=${PIPESTATUS[0]} + set -e + else + cmd=(claude -p --dangerously-skip-permissions) + [[ -z "$MODEL" ]] || cmd+=(--model "$MODEL") + if (( LIVE )) && command -v jq >/dev/null 2>&1; then + cmd+=(--verbose --output-format stream-json) + set +e + "${cmd[@]}" "$prompt" 2>&1 | format_claude_stream | tee -a "$log_file" + status=${PIPESTATUS[0]} + set -e + else + set +e + "${cmd[@]}" "$prompt" 2>&1 | tee -a "$log_file" + status=${PIPESTATUS[0]} + set -e + fi + fi + + printf '\n[%s] session %s (%s) exited with status %s\n' \ + "$(iso_now)" "$name" "$AGENT" "$status" >> "$log_file" + return "$status" +} + +run_task() { + local task="$1" + local task_file="${TASK_DIR}/${task}.md" + local timestamp log_file status gate_status repair_attempt repair_status + + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + log_file="${LOG_DIR}/${timestamp}-${task}.log" + + local prompt + read -r -d '' prompt <&1 | tee -a "$log_file" + gate_status=${PIPESTATUS[0]} + set -e + (( gate_status != 0 )) || break + + if (( repair_attempt >= REPAIR_ATTEMPTS )); then + record_task_state "$task" "FAILED(GATE)" "$log_file" \ + "GS task gate failed" \ + "$task exhausted $REPAIR_ATTEMPTS repair attempts (progress: $(completion_progress))" + return 1 + fi + + repair_attempt=$((repair_attempt + 1)) + record_task_state "$task" "REPAIR(${repair_attempt})" "$log_file" \ + "GS task repair" \ + "$task repair $repair_attempt/$REPAIR_ATTEMPTS after failed validation gate (progress: $(completion_progress))" + + local repair_prompt + read -r -d '' repair_prompt <&2; exit 2; } + AGENT="$2"; shift 2 ;; + --model) + [[ $# -ge 2 ]] || { echo "Missing value for --model" >&2; exit 2; } + MODEL="$2"; shift 2 ;; + --thinking) + [[ $# -ge 2 ]] || { echo "Missing value for --thinking" >&2; exit 2; } + THINKING="$2"; shift 2 ;; + --live) LIVE=1; shift ;; + --no-live) LIVE=0; shift ;; + --no-notify) NOTIFY=0; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --force) FORCE=1; shift ;; + -h|--help) usage; exit 0 ;; + GS-[0-9][0-9][0-9]) TASKS+=("$1"); shift ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[[ -n "$ROOT_DIR" ]] || { echo "Run this script inside the GitDeck repository." >&2; exit 1; } +mkdir -p "$LOG_DIR" +[[ -f "$PLAN_FILE" ]] || { echo "Missing tracked plan: $PLAN_FILE" >&2; exit 1; } +[[ -f "$PROGRESS_FILE" ]] || { echo "Missing tracked progress ledger: $PROGRESS_FILE" >&2; exit 1; } +[[ -x "$GATE" ]] || { echo "Validation gate missing or not executable: $GATE" >&2; exit 1; } +case "$AGENT" in + claude|pi) ;; + *) echo "Invalid --agent: $AGENT (expected pi or claude)." >&2; exit 2 ;; +esac +command -v "$AGENT" >/dev/null 2>&1 || { echo "$AGENT is not available in PATH." >&2; exit 1; } +if [[ -z "$LIVE" ]]; then + [[ -t 1 ]] && LIVE=1 || LIVE=0 +fi +[[ "$LIVE" =~ ^[01]$ ]] || { echo "GS_LOOP_LIVE must be 0 or 1." >&2; exit 1; } +if (( LIVE )) && [[ "$AGENT" == "claude" ]] && ! command -v jq >/dev/null 2>&1; then + echo "Note: jq not found; live streaming disabled (plain claude output)." >&2 +fi +[[ "$REPAIR_ATTEMPTS" =~ ^[0-9]+$ ]] || { + echo "GS_LOOP_REPAIR_ATTEMPTS must be a non-negative integer." >&2; exit 1 +} +[[ "$MAX_TASKS" =~ ^[0-9]+$ ]] || { + echo "GS_LOOP_MAX_TASKS must be a non-negative integer." >&2; exit 1 +} +if [[ -n "$TELEGRAM_BOT_TOKEN" || -n "$TELEGRAM_CHAT_ID" ]]; then + [[ -n "$TELEGRAM_BOT_TOKEN" && -n "$TELEGRAM_CHAT_ID" ]] || { + echo "TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set together." >&2; exit 1 + } + command -v curl >/dev/null 2>&1 || { echo "curl is required for Telegram notifications." >&2; exit 1; } +fi + +current_branch="$(git rev-parse --abbrev-ref HEAD)" +if [[ "$current_branch" != "$BRANCH" ]]; then + if (( DRY_RUN )); then + echo "Note: dry run on branch $current_branch; real runs require $BRANCH (GS_LOOP_BRANCH)." >&2 + else + echo "The runner must be started on $BRANCH (current: $current_branch). Set GS_LOOP_BRANCH to override." >&2 + exit 1 + fi +fi + +cd "$ROOT_DIR" + +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "Another GS task runner appears to be active: $LOCK_DIR" >&2 + exit 1 +fi +trap 'rm -rf "$LOCK_DIR"' EXIT + +if ((${#TASKS[@]} > 0)); then + for task in "${TASKS[@]}"; do + task_file="${TASK_DIR}/${task}.md" + [[ -f "$task_file" ]] || { echo "Task file not found: $task_file" >&2; exit 2; } + progress_status="$(progress_field "$task" 3)" + case "$progress_status" in + PENDING|IN_PROGRESS|BLOCKED|COMPLETED) ;; + *) echo "Invalid PROGRESS.md status for $task: $progress_status" >&2; exit 2 ;; + esac + if [[ "$progress_status" == "COMPLETED" ]] && (( ! FORCE )); then + validate_progress_entry "$task" || exit 1 + record_task_state "$task" "SKIPPED(COMPLETED)" "$task_file" \ + "GS task skipped" "$task is already completed (progress: $(completion_progress))" + continue + fi + if (( DRY_RUN )); then + echo "DRY RUN: would run $task via $AGENT (model: ${MODEL:-default})" + printf '%s\t%s\tDRY-RUN\t%s\n' \ + "$(iso_now)" "$task" "$task_file" >> "$STATE_FILE" + continue + fi + run_task "$task" || exit 1 + done + exit 0 +fi + +# Auto mode: re-scan the ledger after every task so tasks authored mid-run +# (by phase-closing tasks) enter the loop automatically. +ran=0 +while task="$(first_pending_task)"; do + if (( MAX_TASKS > 0 && ran >= MAX_TASKS )); then + echo "Reached GS_LOOP_MAX_TASKS=$MAX_TASKS; stopping." + exit 0 + fi + if (( DRY_RUN )); then + echo "DRY RUN: would run $task via $AGENT (model: ${MODEL:-default}) — auto mode stops here (the task list evolves at runtime)." + exit 0 + fi + run_task "$task" || exit 1 + ran=$((ran + 1)) +done + +notify "GS loop" "No PENDING tasks remain (progress: $(completion_progress))." +notify_telegram "GS loop" "No PENDING tasks remain (progress: $(completion_progress))." diff --git a/growth-studio-project/scripts/validate-gs-task.sh b/growth-studio-project/scripts/validate-gs-task.sh new file mode 100755 index 0000000..1756a41 --- /dev/null +++ b/growth-studio-project/scripts/validate-gs-task.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Local validation gate for the Growth Studio ralph loop. +# Runs every applicable check and caches successful stages for unchanged +# inputs. Guards always run. +set -Eeuo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +PROJECT_DIR="${ROOT_DIR}/growth-studio-project" +CACHE_DIR="${PROJECT_DIR}/.runtime/validation-cache" +CACHE_ENABLED="${GS_VALIDATION_CACHE:-1}" +CACHE_TTL="${GS_VALIDATION_CACHE_TTL:-21600}" # six hours +CACHE_SCHEMA="1" + +step() { printf '\n=== %s ===\n' "$1"; } +skip() { printf -- '--- skipped: %s\n' "$1"; } + +[[ "$CACHE_ENABLED" =~ ^[01]$ ]] || { + echo "GS_VALIDATION_CACHE must be 0 or 1." >&2 + exit 1 +} +[[ "$CACHE_TTL" =~ ^[0-9]+$ ]] || { + echo "GS_VALIDATION_CACHE_TTL must be a non-negative integer." >&2 + exit 1 +} + +# Hash tracked and non-ignored inputs, including their paths. +input_fingerprint() { + local scope="$1" + shift + { + printf 'schema=%s scope=%s\n' "$CACHE_SCHEMA" "$scope" + git -C "$ROOT_DIR" ls-files --cached --others --exclude-standard -- "$@" | + LC_ALL=C sort -u | + while IFS= read -r file; do + [[ -f "${ROOT_DIR}/${file}" ]] || continue + printf '%s\t%s\n' "$file" "$(git -C "$ROOT_DIR" hash-object -- "$file")" + done + } | sha256sum | awk '{ print $1 }' +} + +cache_hit() { + local stage="$1" fingerprint="$2" + local cache_file="${CACHE_DIR}/${stage}.tsv" + local saved_at saved_fingerprint now + + (( CACHE_ENABLED && CACHE_TTL > 0 )) || return 1 + [[ -f "$cache_file" ]] || return 1 + IFS=$'\t' read -r saved_at saved_fingerprint < "$cache_file" || return 1 + [[ "$saved_at" =~ ^[0-9]+$ && "$saved_fingerprint" == "$fingerprint" ]] || return 1 + now="$(date +%s)" + (( now >= saved_at && now - saved_at <= CACHE_TTL )) +} + +cache_save() { + local stage="$1" fingerprint="$2" + (( CACHE_ENABLED )) || return 0 + mkdir -p "$CACHE_DIR" + printf '%s\t%s\n' "$(date +%s)" "$fingerprint" > "${CACHE_DIR}/${stage}.tsv" +} + +fail=0 +cd "$ROOT_DIR" + +step "Guard: git diff --check" +if ! git diff --check; then + echo "ERROR: whitespace errors in the diff." >&2 + fail=1 +fi + +step "Guard: tests live outside src/ (AGENTS.md)" +misplaced="$(find src -type f \( -name '*.test.ts' -o -name '*.test.tsx' -o -name '*.spec.ts' -o -name '*.spec.tsx' \) 2>/dev/null || true)" +if [[ -n "$misplaced" ]]; then + echo "ERROR: test files found under src/:" >&2 + printf '%s\n' "$misplaced" >&2 + fail=1 +fi + +step "Guard: locale files present" +for locale in src/i18n/en.ts src/i18n/it.ts; do + [[ -f "$locale" ]] || { echo "ERROR: missing $locale" >&2; fail=1; } +done + +command -v npm >/dev/null 2>&1 || { echo "npm is required but not in PATH." >&2; exit 1; } +[[ -d node_modules ]] || { step "npm install"; npm install; } + +app_fingerprint="$(input_fingerprint app \ + src tests public index.html package.json package-lock.json tsconfig.json vite.config.ts)" +if cache_hit app "$app_fingerprint"; then + skip "typecheck, unit tests and build (unchanged successful inputs)" +else + app_fail=0 + + step "typecheck" + if npm run typecheck; then :; else fail=1; app_fail=1; fi + + step "unit tests (vitest run)" + if npm test; then :; else fail=1; app_fail=1; fi + + step "production build" + if npm run build; then :; else fail=1; app_fail=1; fi + + (( app_fail )) || cache_save app "$app_fingerprint" +fi + +step "Result" +if (( fail )); then + echo "VALIDATION FAILED" + exit 1 +fi +echo "VALIDATION OK" diff --git a/growth-studio-project/tasks/GS-000.md b/growth-studio-project/tasks/GS-000.md new file mode 100644 index 0000000..4f22fbc --- /dev/null +++ b/growth-studio-project/tasks/GS-000.md @@ -0,0 +1,35 @@ +# GS-000 — Governance baseline + +**Phase:** Phase 0 — Governance and inventory + +- Verify the loop branch (`feat/growth-studio` or `GS_LOOP_BRANCH`) is active and that `git status` is clean apart from this project directory. +- Verify `growth-studio-project/.gitignore` excludes `.runtime/`. +- Verify `scripts/run-gs-tasks.sh` and `scripts/validate-gs-task.sh` are executable and pass `bash -n`. +- Record the Node.js and npm versions and run `npm run typecheck`, `npm test` and `npm run build` on the untouched tree. Record pre-existing warnings verbatim in the summary. +- The baseline currently fails `npm run typecheck` with six pre-existing errors unrelated to Growth Studio (`IssueList.tsx:34`, `PullRequestList.tsx:43`, `TriageWorkspace.tsx:84`: `string | undefined` passed where `string` is expected; `tests/utils/colors.test.ts:32-34`: indexing `CSSProperties` with custom property names). Fix them with the smallest type-correct change (a guard or a `Record` cast in the test), without changing runtime behavior, so the gate can be green for every later task. Do not touch anything else. +- Confirm `pi --version` and `jq --version` are available; document both. + +Acceptance: + +- Gate returns `VALIDATION OK` on the baseline tree. +- Runner scripts pass `bash -n`. + +## Baseline summary + +- Branch: `feat/repository-goals-social-plans` (`GS_LOOP_BRANCH` matches). +- Initial worktree: clean apart from the untracked `growth-studio-project/` directory. +- Tooling: Node.js `v22.18.0`, npm `10.9.3`, pi `0.84.4`, jq `jq-1.8.2`. +- Both runner scripts were executable with mode `755` and passed `bash -n`. +- Initial checks: `npm run typecheck` reported the six expected errors; `npm test` passed 28 files and 151 tests; `npm run build` passed with this pre-existing warning: + +```text +[plugin builtin:vite-reporter] +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +``` + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-001.md b/growth-studio-project/tasks/GS-001.md new file mode 100644 index 0000000..40069a6 --- /dev/null +++ b/growth-studio-project/tasks/GS-001.md @@ -0,0 +1,17 @@ +# GS-001 — Inventory verification + +**Phase:** Phase 0 — Governance and inventory + +- Read every file listed in `docs/GS_PLAN.md` section 4 and confirm the described responsibilities are accurate. Fix the plan where the code differs (for example function names, route paths, table columns). +- Enumerate every `goals.*` and `tabs.goals` i18n key in `src/i18n/en.ts` and `src/i18n/it.ts` and list them in a new section 4.1 of the plan, so later tasks know what moves into the growth shell. +- Enumerate every consumer of the `goals` tab in `src/App.tsx` (state, effects, filters, body classes, route table) and list them in section 4.2 of the plan, so GS-012 can remove them safely. +- Verify `docs/GS_FEATURE_MATRIX.md` `EXISTING` rows against the code; correct locations. + +Acceptance: + +- Plan sections 4, 4.1 and 4.2 match the code exactly. +- No source file changes in this task. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-002.md b/growth-studio-project/tasks/GS-002.md new file mode 100644 index 0000000..7885464 --- /dev/null +++ b/growth-studio-project/tasks/GS-002.md @@ -0,0 +1,16 @@ +# GS-002 — Decisions and open questions + +**Phase:** Phase 0 — Governance and inventory + +- Read `docs/GS_DECISIONS.md`. For each phase 1 and phase 2 task, list any design choice the task files leave implicit (for example: how `main.tsx` selects the shell, how the shell reuses authentication state from `App.tsx`, how account switching behaves inside the shell). +- Settle each one by studying `src/main.tsx`, `src/App.tsx`, `src/contexts/AccountContext.tsx` and `src/components/AuthGate.tsx`, then append a `D-0xx` row with rationale. Prefer reusing existing providers and hooks over new ones. +- Where a choice cannot be settled from the code, write the two options and the recommended one in the decision row and mark it `provisional`. + +Acceptance: + +- Every phase 1 and 2 task can be executed without asking a design question. +- No source file changes in this task. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-003.md b/growth-studio-project/tasks/GS-003.md new file mode 100644 index 0000000..c61f3cd --- /dev/null +++ b/growth-studio-project/tasks/GS-003.md @@ -0,0 +1,16 @@ +# GS-003 — Gate hardening + +**Phase:** Phase 0 — Governance and inventory + +- Run `scripts/validate-gs-task.sh` twice; the second run must hit the cache for typecheck, tests and build. +- Add a fixture-free check to the gate that fails when a `*.test.ts` or `*.test.tsx` file exists under `src/` (AGENTS.md rule) and one that fails when an i18n key is present in `src/i18n/en.ts` but missing from `src/i18n/it.ts` or vice versa. Implement the key comparison with a small `node --input-type=module -e` script or a `scripts/` helper; no new dependencies. +- Confirm the gate still returns `VALIDATION OK` and that the new checks fail on a deliberately broken temporary copy (do not commit the broken state). + +Acceptance: + +- Gate passes on the clean tree and its cache works. +- Both new guards demonstrated failing and passing in the summary. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-010.md b/growth-studio-project/tasks/GS-010.md new file mode 100644 index 0000000..87c3039 --- /dev/null +++ b/growth-studio-project/tasks/GS-010.md @@ -0,0 +1,15 @@ +# GS-010 — Growth routes served by the SPA + +**Phase:** Phase 1 — Shell + +- In `src/server/spa.ts`, make `/growth` and every `/growth/...` path resolve as a client route (keep the extension check so assets are unaffected). Add a unit test under `tests/server/spa.test.ts` covering `/growth`, `/growth/r/owner/repo/calendar`, `/growth/calendar.ics` (not a client route) and an unrelated asset path. +- In `src/main.tsx`, mount a new `GrowthStudioApp` (placeholder component in `src/components/growth/GrowthStudioApp.tsx` rendering a heading) when the pathname starts with `/growth`, otherwise the existing `App`. Keep shared providers around both. Follow the decision rows from GS-002. +- Verify `npm run dev` serves `/growth` and `/growth/r/debba/gitdeck` with the placeholder and that `/repositories` is unchanged. + +Acceptance: + +- Both roots render; SPA test passes; dashboard untouched. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-011.md b/growth-studio-project/tasks/GS-011.md new file mode 100644 index 0000000..adbeeb5 --- /dev/null +++ b/growth-studio-project/tasks/GS-011.md @@ -0,0 +1,17 @@ +# GS-011 — Growth shell chrome + +**Phase:** Phase 1 — Shell + +- Build the shell in `src/components/growth/`: `GrowthStudioApp.tsx` (router with the routes from plan section 5.1 as placeholders), `GrowthTopBar.tsx` (product name, repository switcher using `RepositoryPicker`, account avatar and theme toggle reusing existing controls), `GrowthSidebar.tsx` (Home, Unified calendar, Review, Settings; then the selected repository section with Overview, Missions, Interventions, Calendar, Library, Review). +- Set body class `mode-growth` while the shell is mounted; render no dashboard `SidebarControls`, tab strip or `Footer`. +- Create `src/styles/growth/shell.css` imported from `src/styles.css`; use tokens from `src/styles/tokens.css`; two-column layout with a collapsible sidebar under 960px, mirroring the breakpoints in `layout-sidebar.css`. +- Reuse authentication: when the account state is anonymous, render the existing `AuthGate`. +- Add i18n keys under `growth.*` in both locales. + +Acceptance: + +- Navigating between sidebar entries changes the URL and highlights the active entry; the dashboard chrome is not present; dark and light themes render correctly. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-012.md b/growth-studio-project/tasks/GS-012.md new file mode 100644 index 0000000..ea7f3ee --- /dev/null +++ b/growth-studio-project/tasks/GS-012.md @@ -0,0 +1,16 @@ +# GS-012 — Main-menu entry and goals tab removal + +**Phase:** Phase 1 — Shell + +- In `src/App.tsx`, replace the `goals` tab with an anchor styled as a tab (`className="tab"`) pointing to `/growth` with `target="_blank"` and `rel="noopener"`, using `GoalIcon` and the label `tabs.growthStudio` (both locales). Show no count badge. +- Remove `goals` from the `Tab` union, `TAB_ROUTES`, the `tab-goals` body class, the goals state and effects, and the filter branches listed in plan section 4.2. Keep `fetchGoals` and the goals API in `src/api/github.ts` for the Missions panel. +- Redirect `/goals` to `/growth` client-side (in `GrowthStudioApp` routing, or in `main.tsx` before mounting) and keep `/goals` in `APP_ROUTES`. +- Update `SidebarControls.tsx` `Tab` type accordingly. + +Acceptance: + +- The dashboard shows the Growth Studio entry that opens a new window; `/goals` lands on `/growth`; typecheck passes with no `goals` tab references left in `App.tsx`. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-013.md b/growth-studio-project/tasks/GS-013.md new file mode 100644 index 0000000..3f9ef6a --- /dev/null +++ b/growth-studio-project/tasks/GS-013.md @@ -0,0 +1,15 @@ +# GS-013 — Missions panel + +**Phase:** Phase 1 — Shell + +- Route `/growth/r/:owner/:repo/missions` renders the existing `GoalsView` scoped to the repository: the create form pre-selects and locks the repository, and only that repository's goals are listed. Load goals with `fetchGoals` inside the shell (a `useGoals` hook in `src/components/growth/hooks/` or `src/hooks/`), refreshing after create, delete and advice. +- Keep `GoalProposalsModal` working from the panel; the preferences link navigates to `/preferences#preferences-ai` in the main application (open in a new window from the shell). +- Move `src/styles/goals.css` import unchanged; do not restyle the goals UI in this task. + +Acceptance: + +- Creating, deleting and advising a goal works from the shell exactly as it did in the dashboard; existing tests pass. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-014.md b/growth-studio-project/tasks/GS-014.md new file mode 100644 index 0000000..576efd6 --- /dev/null +++ b/growth-studio-project/tasks/GS-014.md @@ -0,0 +1,16 @@ +# GS-014 — Growth home + +**Phase:** Phase 1 — Shell + +- Route `/growth` lists repositories that have goals (from `fetchGoals`) plus a "start with a repository" picker for any other repository from `/api/repos`. Each card shows avatar, name, description, number of goals, completed goals, and links to the workspace overview and to Missions. +- Empty state when no goal exists yet, pointing to the picker. +- Loading and error states; reuse `GoalsLoadingState` where it fits. +- Styles in `src/styles/growth/home.css`. + +Acceptance: + +- Home renders cards for every repository with goals; picking a repository navigates to `/growth/r/:owner/:repo`. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-015.md b/growth-studio-project/tasks/GS-015.md new file mode 100644 index 0000000..72d0338 --- /dev/null +++ b/growth-studio-project/tasks/GS-015.md @@ -0,0 +1,14 @@ +# GS-015 — Workspace overview + +**Phase:** Phase 1 — Shell + +- Route `/growth/r/:owner/:repo` shows: repository identity header; goal progress summary (reuse `calculateGoalProgress`); counters for interventions by status and content items by status (placeholders reading zero until phase 2 provides the API; implement against a typed `GrowthWorkspaceSummary` in `src/types/growth.ts` with a client stub that phase 2 replaces); "next 7 days" list placeholder; quick actions to Missions, Interventions, Calendar and Library. +- Add `src/types/growth.ts` with the entity types from plan section 5.2 (profiles, interventions, plans, items, assets, performance) so phase 2 implements against them. + +Acceptance: + +- Overview renders for a repository with and without goals; types compile and are exported. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-016.md b/growth-studio-project/tasks/GS-016.md new file mode 100644 index 0000000..ed3b075 --- /dev/null +++ b/growth-studio-project/tasks/GS-016.md @@ -0,0 +1,16 @@ +# GS-016 — Shell polish and phase 2 readiness + +**Phase:** Phase 1 — Shell + +- Review every shell screen at 1440, 1024 and 390 pixels wide in both themes; fix layout defects; confirm keyboard focus order and Escape handling on the repository switcher. +- Verify every `growth.*` key exists in both locales and that the Italian copy is natural. +- Update the README of the main repository with a short "Growth Studio" section (what it is, how it opens) and a screenshot placeholder reference. +- Confirm the `tasks/GS-02x.md` files and their `PENDING` ledger rows exist and still match plan section 5.2 and 5.3 after phases 0 and 1; adjust wording where phase 1 changed file names. + +Acceptance: + +- No layout defects at the three widths; locale parity guard passes; phase 2 tasks are consistent with the shipped shell. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-020.md b/growth-studio-project/tasks/GS-020.md new file mode 100644 index 0000000..f678445 --- /dev/null +++ b/growth-studio-project/tasks/GS-020.md @@ -0,0 +1,16 @@ +# GS-020 — Growth store schema + +**Phase:** Phase 2 — Data model + +- Create `src/server/growth/store.ts` with idempotent schema creation for `growth_profiles`, `growth_interventions`, `content_plans`, `content_items`, `growth_assets` and `content_performance` exactly as in plan section 5.2, plus indexes on (`account_id`, `repository`) and on `content_items(account_id, scheduled_for)`. +- Implement typed CRUD: profiles get or upsert (with defaults: pillars Release, Educational, Community, Behind the scenes, Milestones; cadence x 3, linkedin 1, mastodon 3; language from the profile or `en`); interventions list, create, update status; content items list with filters (repository, status, date range), create, update, reschedule, mark published, delete; plans create and archive. +- Enforce in the store that `ready`, `scheduled` and `published` require `media.length > 0`; throw a typed `MediaRequiredError`. +- Tests under `tests/server/growthStore.test.ts` using an isolated database path (see `closeDatabase` in `src/server/sqlite.ts`). + +Acceptance: + +- Store tests cover defaults, filters, status transitions and the media rule. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-021.md b/growth-studio-project/tasks/GS-021.md new file mode 100644 index 0000000..ebfdce9 --- /dev/null +++ b/growth-studio-project/tasks/GS-021.md @@ -0,0 +1,15 @@ +# GS-021 — Legacy suggestions migration + +**Phase:** Phase 2 — Data model + +- In `src/server/growth/store.ts`, implement `migrateLegacySuggestions(accountId)` that reads `repository_goals.suggestions` and creates `growth_interventions` (`origin='ai'`, `status='proposed'`, `goal_id` set, `dedupe_key` from repository, goal id and title) and `content_items` (`status='draft'`, channel derived from the proposal format, media from `mediaSuggestions`, `generation_version` from `proposalsVersion`). Run it once per account, guarded by a `preferences` row (`growth`, `migratedSuggestionsV1:`). +- Stop writing to `repository_goals.suggestions` from `saveGoalSuggestions` and `saveGoalProposals`: new suggestions become interventions and new proposals become content items. Keep the goals API response shape by projecting interventions back into `suggestions` for the Missions panel until GS-023 replaces that view. +- Tests in `tests/server/growthStore.test.ts` for idempotency and mapping. + +Acceptance: + +- Existing goals keep showing their suggestions and proposals; rerunning the migration creates no duplicates. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-022.md b/growth-studio-project/tasks/GS-022.md new file mode 100644 index 0000000..e4ee6d7 --- /dev/null +++ b/growth-studio-project/tasks/GS-022.md @@ -0,0 +1,15 @@ +# GS-022 — Growth API routes + +**Phase:** Phase 2 — Data model + +- Create `src/server/routes/growth.ts` registered from `routes/index.ts`, prefix `/api/growth/`: `GET workspace/:owner/:repo` (summary for the overview), `GET|PUT profiles/:owner/:repo`, `GET|POST interventions`, `PATCH interventions/:id`, `GET|POST content`, `PATCH content/:id` (fields, status, schedule), `POST content/:id/published` (url), `DELETE content/:id`. Every handler validates input like `routes/goals.ts` and scopes by the active account. +- Create `src/api/growth.ts` with typed fetchers and replace the GS-015 client stub. +- Tests in `tests/server/growthRoutes.test.ts` for validation errors and account scoping, following the style of existing server tests. + +Acceptance: + +- Overview counters read real data; all routes reject invalid bodies with 400. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-023.md b/growth-studio-project/tasks/GS-023.md new file mode 100644 index 0000000..c588cfe --- /dev/null +++ b/growth-studio-project/tasks/GS-023.md @@ -0,0 +1,17 @@ +# GS-023 — Interventions backlog + +**Phase:** Phase 2 — Data model + +- Route `/growth/r/:owner/:repo/interventions`: list interventions grouped by status (proposed, accepted, done, dismissed collapsed), with category chip, origin badge, linked goal, and actions accept, dismiss, mark done. Filters by category and origin. +- "Generate interventions" button reuses `generateGoalSuggestions` logic through a new `POST /api/growth/interventions/generate` for a repository (with or without goals; when no goal exists, the prompt is goal-less and grounded in signals only), storing results as interventions with dedupe. +- Manual creation form: title, action, category. +- Content items linked to an intervention are listed inline with their status. +- Styles in `src/styles/growth/interventions.css`; i18n in both locales. + +Acceptance: + +- Accept, dismiss, done and generate work end to end; the Missions panel no longer renders its own suggestion list (link to the backlog instead). + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-024.md b/growth-studio-project/tasks/GS-024.md new file mode 100644 index 0000000..0b7f040 --- /dev/null +++ b/growth-studio-project/tasks/GS-024.md @@ -0,0 +1,15 @@ +# GS-024 — Content items drawer + +**Phase:** Phase 2 — Data model + +- A `ContentItemDrawer` component (right-side panel, Escape closes) showing one content item: channel and format, pillar, status, schedule, body rendered with `Markdown`, thread posts with the existing X thread rendering and character counters, media list, sources, copy text and copy thread buttons (reuse `formatXThreadForCopy`), inline edit of title, body and thread posts, status actions allowed by the store rules, schedule picker, mark published with URL. +- "Draft from intervention" action: reuse `generateGoalProposals` through `POST /api/growth/content/draft` for an intervention (goal optional), creating content items in `draft` status. +- Used from the Interventions panel; later reused by the Calendar. + +Acceptance: + +- Editing, copying, scheduling and marking published persist; media rule errors are shown inline. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-025.md b/growth-studio-project/tasks/GS-025.md new file mode 100644 index 0000000..7f0f6ee --- /dev/null +++ b/growth-studio-project/tasks/GS-025.md @@ -0,0 +1,15 @@ +# GS-025 — Library panel: sources and profile + +**Phase:** Phase 2 — Data model + +- Route `/growth/r/:owner/:repo/library` with sections: Sources (move `RepositoryContentSources` here unchanged in behavior), Profile (language, voice, audience, hashtags, avoid list, timezone, colour), Channels (toggles), Cadence (posts per week per channel), Pillars (editable list with weight and description, defaults from the store), Posting windows (weekday and hour list). +- Save through `PUT /api/growth/profiles/:owner/:repo`; validate on the server (weights 0–100, cadence 0–14, known channels, IANA timezone check via `Intl.DateTimeFormat`). +- Pure validation and normalization in `src/utils/growth/profile.ts` with tests in `tests/utils/growth/profile.test.ts`. + +Acceptance: + +- Profile round-trips; invalid values are rejected with clear messages; the Missions panel no longer shows the sources widget. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/GS-026.md b/growth-studio-project/tasks/GS-026.md new file mode 100644 index 0000000..9171a6f --- /dev/null +++ b/growth-studio-project/tasks/GS-026.md @@ -0,0 +1,15 @@ +# GS-026 — Phase 2 closure and phase 3 authoring + +**Phase:** Phase 2 — Data model + +- Verify every phase 2 feature row in `docs/GS_FEATURE_MATRIX.md` is `DONE` with accurate locations. +- Author the phase 3 task files `GS-030` to `GS-038` and their `PENDING` rows in `PROGRESS.md`, following plan sections 5.3, 5.4 and 6: `planSlots.ts` slot builder with tests (GS-030); planner endpoint and prompt with fallback (GS-031); drafter endpoint producing media-aware drafts from assets and signal media (GS-032); calendar month view (GS-033); calendar week view and drag and drop reschedule (GS-034); queue "this week" with copy and mark published (GS-035); ICS export (GS-036); plan management UI: create, regenerate, archive (GS-037); phase 3 closure and phase 4 authoring (GS-038). Each task file follows the structure of the existing ones, names the exact files to create, and ends with the completion record section. +- Record any new decision in `docs/GS_DECISIONS.md`. + +Acceptance: + +- Phase 3 tasks exist, are specific, and reference only files and helpers that exist or that they create. + +## Completion record + +Before finishing, update the matching row in `growth-studio-project/tasks/PROGRESS.md` with `COMPLETED` or `BLOCKED`, a concise summary, verification evidence, and the ISO date. Do not use `|` inside progress fields. Update `docs/GS_FEATURE_MATRIX.md` rows touched by this task. Create the task's single conventional commit (scope `growth`, no `Co-Authored-By` trailer) including the PROGRESS.md update. Do not push. diff --git a/growth-studio-project/tasks/PROGRESS.md b/growth-studio-project/tasks/PROGRESS.md new file mode 100644 index 0000000..e41dfd9 --- /dev/null +++ b/growth-studio-project/tasks/PROGRESS.md @@ -0,0 +1,29 @@ +# Growth Studio task progress + +This file is the authoritative completion ledger for +`growth-studio-project/scripts/run-gs-tasks.sh`. +Allowed statuses are `PENDING`, `IN_PROGRESS`, `BLOCKED`, and `COMPLETED`. +A completed row must include a concise summary, verification evidence, and an +ISO date. Do not use `|` inside table fields. Tasks authored later (by +phase-closing tasks) are appended with `PENDING` rows and matching task files. + +| Task | Status | Summary | Verification | Updated | +| ------ | ------- | ------- | ------------ | ------- | +| GS-000 | COMPLETED | Established the governance baseline and fixed six pre-existing type errors without runtime changes | VALIDATION OK; typecheck; 28 test files and 151 tests; production build; runner scripts bash-n; Node v22.18.0; npm 10.9.3; pi 0.84.4; jq 1.8.2 | 2026-09-04 | +| GS-001 | PENDING | — | — | — | +| GS-002 | PENDING | — | — | — | +| GS-003 | PENDING | — | — | — | +| GS-010 | PENDING | — | — | — | +| GS-011 | PENDING | — | — | — | +| GS-012 | PENDING | — | — | — | +| GS-013 | PENDING | — | — | — | +| GS-014 | PENDING | — | — | — | +| GS-015 | PENDING | — | — | — | +| GS-016 | PENDING | — | — | — | +| GS-020 | PENDING | — | — | — | +| GS-021 | PENDING | — | — | — | +| GS-022 | PENDING | — | — | — | +| GS-023 | PENDING | — | — | — | +| GS-024 | PENDING | — | — | — | +| GS-025 | PENDING | — | — | — | +| GS-026 | PENDING | — | — | — | diff --git a/src/utils/colors.ts b/src/utils/colors.ts index bc8f311..381d8e5 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -32,7 +32,7 @@ export function rgbToHsl(r: number, g: number, b: number): [number, number, numb return [hue * 60, saturation * 100, lightness * 100]; } -export function getLabelCssVars(hex: string): CSSProperties | undefined { +export function getLabelCssVars(hex: string | undefined): CSSProperties | undefined { const cleaned = (hex || "").replace("#", "").trim(); if (cleaned.length < 6) return undefined; const r = Number.parseInt(cleaned.slice(0, 2), 16); diff --git a/tests/utils/colors.test.ts b/tests/utils/colors.test.ts index 24ee6da..daf85c0 100644 --- a/tests/utils/colors.test.ts +++ b/tests/utils/colors.test.ts @@ -29,9 +29,10 @@ describe("color utilities", () => { "--label-g": "202", "--label-b": "4", }); - expect(vars && vars["--label-h"]).toMatch(/^\d+$/); - expect(vars && vars["--label-s"]).toMatch(/^\d+$/); - expect(vars && vars["--label-l"]).toMatch(/^\d+$/); + const customProperties = vars as Record; + expect(customProperties["--label-h"]).toMatch(/^\d+$/); + expect(customProperties["--label-s"]).toMatch(/^\d+$/); + expect(customProperties["--label-l"]).toMatch(/^\d+$/); }); it("accepts hex colors with leading hash", () => { From 14ad75f08c6439e6c379425a85f3492238ff5bb6 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 09:06:14 +0200 Subject: [PATCH 04/54] docs(growth): verify Growth Studio inventory --- .../docs/GS_FEATURE_MATRIX.md | 10 +- growth-studio-project/docs/GS_PLAN.md | 146 ++++++++++++++++-- growth-studio-project/tasks/PROGRESS.md | 2 +- 3 files changed, 137 insertions(+), 21 deletions(-) diff --git a/growth-studio-project/docs/GS_FEATURE_MATRIX.md b/growth-studio-project/docs/GS_FEATURE_MATRIX.md index c400e98..be44139 100644 --- a/growth-studio-project/docs/GS_FEATURE_MATRIX.md +++ b/growth-studio-project/docs/GS_FEATURE_MATRIX.md @@ -7,11 +7,11 @@ Authoritative inventory of Growth Studio capabilities. Statuses: `EXISTING` | Feature | Location (current or target) | Status | Task | |---|---|---|---| | Governance baseline and validation gate | `growth-studio-project/scripts/`, `src/utils/colors.ts`, `tests/utils/colors.test.ts` | DONE | GS-000 | -| Goals CRUD with metric refresh (stars, forks, closed PRs, downloads) | `GoalsView.tsx`, `server/goals.ts`, `routes/goals.ts` | EXISTING | — | -| AI suggestions per goal (3–5 actions) with deterministic fallback | `server/goals.ts` `generateGoalSuggestions` | EXISTING | GS-021 migrates | -| AI proposals per suggestion (thread, LinkedIn, Mastodon, issue, doc…) with media suggestions | `server/goals.ts` `generateGoalProposals`, `GoalProposalsModal.tsx` | EXISTING | GS-021 migrates | -| Repository content sources (repositories and websites) with SSRF guards | `goalStore.ts`, `routes/repository.ts`, `RepositoryContentSources.tsx` | EXISTING | GS-025 moves to Library | -| AI provider settings and connection test | `preferences/AiIntegrationSettings.tsx`, `server/ai/*` | EXISTING | — | +| Goals CRUD with metric refresh (stars, forks, closed PRs, downloads) | `src/components/views/GoalsView.tsx`, `src/api/github.ts`, `src/server/goalStore.ts`, `src/server/goals.ts`, `src/server/routes/goals.ts` | EXISTING | — | +| AI suggestions per goal (3–5 actions); four deterministic actions when AI is not configured | `src/server/goals.ts` `generateGoalSuggestions`, `src/server/goalStore.ts` `saveGoalSuggestions`, `src/components/views/GoalsView.tsx` | EXISTING | GS-021 migrates | +| AI proposals per suggestion (X thread, LinkedIn, Mastodon) with source-backed media suggestions when assets are available | `src/server/goals.ts` `generateGoalProposals`, `src/utils/socialProposals.ts`, `src/server/goalStore.ts` `saveGoalProposals`, `src/components/modals/GoalProposalsModal.tsx` | EXISTING | GS-021 migrates | +| Repository content sources (repositories and websites) with SSRF-guarded website reads during generation | `src/server/goalStore.ts`, `src/server/routes/repository.ts`, `src/server/goals.ts`, `src/api/github.ts`, `src/components/common/RepositoryContentSources.tsx`, `src/components/common/ContentSourcePicker.tsx`, `src/utils/socialProposals.ts` | EXISTING | GS-025 moves to Library | +| AI provider settings and connection test | `src/components/preferences/AiIntegrationSettings.tsx`, `src/api/github.ts`, `src/server/routes/ai.ts`, `src/server/ai/client.ts`, `src/server/ai/settings.ts`, `src/server/ai/providers.ts` | EXISTING | — | | `/growth` client routes served by the SPA | `server/spa.ts`, `main.tsx` | PLANNED | GS-010 | | Growth shell: own top bar, sidebar, `mode-growth` body class, no dashboard chrome | `components/growth/GrowthStudioApp.tsx`, `styles/growth/shell.css` | PLANNED | GS-011 | | Main-menu entry opening `/growth` in a new window; `goals` tab removed; `/goals` redirect | `App.tsx` | PLANNED | GS-012 | diff --git a/growth-studio-project/docs/GS_PLAN.md b/growth-studio-project/docs/GS_PLAN.md index 7128ef4..92dcbbb 100644 --- a/growth-studio-project/docs/GS_PLAN.md +++ b/growth-studio-project/docs/GS_PLAN.md @@ -57,25 +57,141 @@ tool of GitDeck: - D-006 The loop runs with pi by default; Telegram notifications reuse the Emailchef runner variables. -## 4. Current state (inventory, 2026-09-04) +## 4. Current state (inventory, verified 2026-09-04) Files a task will most often touch or extend: -| Area | Files | Notes | +| Area | Files | Verified responsibilities | |---|---|---| -| Types | `src/types/goals.ts` | `RepositoryGoal`, `GoalSuggestion` (the current "intervention"), `GoalProposal` (the current draft), `GoalContentSource`, metric definitions | -| Server store | `src/server/goalStore.ts` | SQLite tables `repository_goals` (suggestions and proposals stored as JSON in `suggestions`) and `repository_content_sources` | -| Server logic | `src/server/goals.ts` | metric resolvers, `generateGoalSuggestions`, `generateGoalProposals`, README/release/website signal fetching with SSRF guards, `SOCIAL_PROPOSALS_VERSION` | -| Routes | `src/server/routes/goals.ts`, `src/server/routes/repository.ts` (content sources), `src/server/routes/index.ts` | `/api/goals*` | -| AI | `src/server/ai/client.ts` (`generateStructured`, `AiNotConfiguredError`, `AiRequestError`), `src/server/ai/settings.ts` (`isAiConfigured`), `src/server/aiDigest.ts` | provider-agnostic structured JSON generation | -| Data helpers | `src/server/dashboardData.ts` (cached repos, issues, PRs), `src/server/githubClient.ts` (`restApi`, `restApiPaginate`, `ghApiJson`), `src/server/snapshots.ts` (daily stars and forks history, 90 days), `src/server/digests.ts` | reuse for signals and attribution | -| Persistence helpers | `src/server/sqlite.ts` (`getDatabase`, `run`, `get`, `all`), `src/server/preferenceStore.ts` (JSON preferences by scope and key) | | -| SPA | `src/server/spa.ts` (`APP_ROUTES`, `isClientRoutePath`), `src/main.tsx` (`BrowserRouter`), `src/App.tsx` (`Tab`, `TAB_ROUTES`, `tabs`, body classes `tab-*`) | | -| UI | `src/components/views/GoalsView.tsx`, `src/components/views/GoalsLoadingState.tsx`, `src/components/modals/GoalProposalsModal.tsx`, `src/components/common/RepositoryPicker.tsx`, `RepositoryContentSources.tsx`, `ContentSourcePicker.tsx`, `src/components/preferences/AiIntegrationSettings.tsx` | | -| Client API | `src/api/github.ts` (`fetchGoals`, `createGoal`, `deleteGoal`, `generateGoalAdvice`, `fetchGoalProposals`, content sources) | | -| Pure utils and tests | `src/utils/goals.ts`, `src/utils/socialProposals.ts`; `tests/utils/goals.test.ts`, `tests/utils/socialProposals.test.ts`, `tests/server/aiClient.test.ts`, `tests/server/aiSettings.test.ts` | | -| Styles | `src/styles/goals.css`, `src/styles/layout-sidebar.css`, `src/styles/navigation.css`, `src/styles/tokens.css` | | -| Scripts | `package.json`: `dev`, `build`, `test` (vitest run), `typecheck` | | +| Types | `src/types/goals.ts` | Defines the four metrics in `GOAL_METRIC_DEFINITIONS`; `RepositoryGoal`; `GoalSuggestion`; `GoalProposal`; `GoalProposalsData`; `GoalContentSource`; media suggestions; and all ten legacy proposal formats. The current generator emits only `x-thread`, `linkedin-post`, and `mastodon-post`. | +| Server store | `src/server/goalStore.ts` | Lazily creates `repository_goals` with columns `id`, `account_id`, `repository`, `metric`, `target_value`, `current_value`, `deadline`, `created_at`, `updated_at`, `suggestions`, and `suggestions_generated_at`, plus `repository_content_sources` with `account_id`, `repository`, `sources`, and `updated_at`. Suggestions and their nested proposals are JSON in `repository_goals.suggestions`. Exports account-scoped goal CRUD, current-value updates, suggestion/proposal saves, and content-source get/save helpers. | +| Server logic | `src/server/goals.ts` | `refreshGoal` uses metric resolvers for stars, forks, closed PRs, and release-asset downloads. `generateGoalSuggestions` uses a four-item `fallbackSuggestions` result when AI is not configured or structured generation yields an empty list, and otherwise requests 3–5 suggestions. `generateGoalProposals` requires AI and requests exactly one X thread, LinkedIn post, and Mastodon post. The module also privately fetches README and release signals, fetches repository and website source signals, guards website requests and redirects against SSRF, and exports `SOCIAL_PROPOSALS_VERSION` (currently `4`). | +| Goal routes | `src/server/routes/goals.ts` | Registers `GET /api/goals`, `POST /api/goals`, `DELETE /api/goals/:id`, `POST /api/goals/:id/advice`, and `POST /api/goals/:id/suggestions/:index/proposals`. Every handler requires the active account; create validates repository, metric, positive integer target, and date. | +| Content-source routes | `src/server/routes/repository.ts`, `src/server/routes/index.ts` | `registerRepositoryRoutes` registers account-scoped `GET` and `PUT /api/repository-content-sources?repo=owner/name`; the same route module also owns repository details, stargazers, forks, branches, and discussions. `registerApiRoutes` registers both repository and goal routes. | +| AI | `src/server/ai/client.ts`, `src/server/ai/settings.ts`, `src/server/aiDigest.ts` | `generateStructured` provides provider-specific structured JSON generation and throws `AiNotConfiguredError` or `AiRequestError`; `testAiConnection` performs the connection test. Settings resolve database overrides, environment variables, and defaults, expose `isAiConfigured`, and persist through `preferenceStore`. `maybeGenerateAiDigest` is a separate optional structured-output consumer. | +| Dashboard data | `src/server/dashboardData.ts` | Exports five-minute memoized `getReposCached`, `getIssuesCached`, and `getPullRequestsCached` loaders plus `invalidateDataCache`. Repository loads best-effort record and attach snapshots. | +| GitHub API helpers | `src/server/githubClient.ts` | Server-only authenticated GitHub GraphQL and REST helpers: `gql`, `restApi`, `restApiPaginate`, and the `ghApiJson` alias. | +| Historical data | `src/server/snapshots.ts`, `src/server/digests.ts` | Snapshots persist up to 90 daily star/fork records per repository in a JSON file, but `attachHistory` exposes only the latest 30. No raw snapshot reader is exported. Digests persist up to 120 daily records in a separate JSON file and expose daily/period delivery plus `getLatestRepoDigest`; AI enrichment is optional. | +| Persistence helpers | `src/server/sqlite.ts`, `src/server/preferenceStore.ts` | SQLite exports `getDatabase`, `execute`, `run`, `get`, `all`, and `closeDatabase`; the singleton database enables WAL and foreign keys. Preferences lazily create a global `preferences(scope, key, value, updated_at)` table and expose JSON `setPreference`, `getPreference`, and `deletePreference`. | +| SPA and entry point | `src/server/spa.ts`, `src/main.tsx` | The private `APP_ROUTES` set contains the fixed top-level routes including `/goals`; `isAppRoute` tests that set, while `isClientRoutePath` accepts any extensionless final path segment. `main.tsx` mounts only `App` inside shared `I18nProvider`, `AccountProvider`, and `BrowserRouter`. | +| Dashboard tab | `src/App.tsx` | Defines the `Tab` union and `TAB_ROUTES`, derives a tab from the pathname, owns goal loading state/effects, applies `tab-goals`, gives Goals the repository-filter search branch, renders the goals tab button, and mounts `GoalsView`. See section 4.2. | +| Goals UI | `src/components/views/GoalsView.tsx`, `src/components/views/GoalsLoadingState.tsx`, `src/components/modals/GoalProposalsModal.tsx` | `GoalsView` creates/deletes goals, groups them by repository, refreshes advice, opens source and proposal modals, and links to AI preferences. The loading component is a layout-matched skeleton. The proposal modal loads/caches/regenerates drafts, handles no-AI/error states, copies text, renders X posts and media, and closes on Escape. | +| Shared goals controls | `src/components/common/RepositoryPicker.tsx`, `src/components/common/RepositoryContentSources.tsx`, `src/components/common/ContentSourcePicker.tsx` | Searchable repository combobox; account-scoped source-library modal with queued auto-save; and repository/website source editor. `RepositoryPicker` currently contains the hard-coded English strings `repositories`, `No repositories found`, and `No description`; the other two use `goals.*` translations. | +| AI settings UI | `src/components/preferences/AiIntegrationSettings.tsx` | Loads, edits, tests, and resets the server-side AI provider settings and displays each resolved setting source. It is mounted by `PreferencesView` at `/preferences#preferences-ai`. | +| Client API | `src/api/github.ts` | Goal methods are `fetchGoals`, `createGoal`, `deleteGoal`, `generateGoalAdvice`, and `fetchGoalProposals`. Source methods are `fetchRepositoryContentSources` and `updateRepositoryContentSources`; AI settings methods are in the same module. | +| Pure goal utils | `src/utils/goals.ts`, `tests/utils/goals.test.ts` | Groups goals by repository, calculates bounded progress/deadline state, and formats an X thread for copying; mirrored tests cover all three. | +| Pure proposal utils | `src/utils/socialProposals.ts`, `tests/utils/socialProposals.test.ts` | Normalizes source entries, extracts web text/media URLs, attaches source media, counts/validates platform content, normalizes AI proposals, and checks the three-format social set; mirrored tests cover these behaviors. | +| AI tests | `tests/server/aiClient.test.ts`, `tests/server/aiSettings.test.ts` | Cover JSON parsing and provider wire formats/errors, plus settings precedence, reset, provider switching, and URL validation. | +| i18n | `src/i18n/en.ts`, `src/i18n/it.ts` | Both files contain the same 70-key Goals set listed in section 4.1. | +| Styles | `src/styles/goals.css`, `src/styles/layout-sidebar.css`, `src/styles/navigation.css`, `src/styles/tokens.css` | Goals CSS also owns repository-picker, source-picker, and proposals-modal styles. Layout/sidebar and navigation own dashboard chrome and tabs; tokens define dark, light, and automatic-theme values. All four are imported by `src/styles.css`. | +| Scripts | `package.json` | `dev` runs the TSX API watcher and Vite concurrently; `build` bundles the Node server with esbuild then runs Vite; `test` is `vitest run`; `typecheck` is `tsc --noEmit`. | + +### 4.1 Goals i18n inventory + +`src/i18n/en.ts` and `src/i18n/it.ts` have identical key sets: one +`tabs.goals` key and 69 `goals.*` keys. These are the keys that later shell and +Missions work must preserve or deliberately replace: + +```text +tabs.goals +goals.createTitle +goals.createDescription +goals.repository +goals.chooseRepository +goals.searchRepository +goals.metric +goals.target +goals.deadline +goals.add +goals.emptyTitle +goals.emptyText +goals.deleteConfirm +goals.deleteTitle +goals.deleteMessage +goals.completed +goals.remaining +goals.overdue +goals.daysLeft +goals.aiPlan +goals.mission +goals.completedMissions +goals.growthStudioEyebrow +goals.growthStudio +goals.growthStudioDescription +goals.generateAdvice +goals.refreshAdvice +goals.proposals +goals.proposalsOpen +goals.proposalsKind +goals.proposalsIntro +goals.sourcesTitle +goals.sourcesDescription +goals.sourcesRepository +goals.sourcesChooseRepository +goals.sourcesWebsite +goals.sourcesAdd +goals.sourcesRepoBadge +goals.sourcesWebBadge +goals.sourcesRemove +goals.sourcesInvalid +goals.sourcesLimit +goals.sourcesOptional +goals.mediaTitle +goals.mediaImage +goals.mediaVideo +goals.proposalsReadyTitle +goals.proposalsReadyText +goals.proposalsGenerate +goals.proposalsRetry +goals.proposalsLoading +goals.proposalsRegenerate +goals.proposalsRegenerateSources +goals.proposalsGeneratedAt +goals.proposalsNoAi +goals.proposalsOpenPreferences +goals.proposalsEmpty +goals.proposalFormat.x-thread +goals.proposalFormat.linkedin-post +goals.proposalFormat.mastodon-post +goals.proposalFormat.post +goals.proposalFormat.issue +goals.proposalFormat.discussion +goals.proposalFormat.email +goals.proposalFormat.checklist +goals.proposalFormat.message +goals.proposalFormat.doc +goals.copyThread +goals.copyPost +goals.aiFallback +``` + +Four of these keys are currently defined in both locales but have no source +consumer: `goals.chooseRepository`, `goals.deleteConfirm`, +`goals.sourcesRepository`, and `goals.proposalsRegenerateSources`. The proposal +format lookup is dynamic, so all `goals.proposalFormat.*` keys remain reachable +for legacy stored proposals even though new generation emits only three social +formats. + +### 4.2 `goals` tab consumers in `src/App.tsx` + +GS-012 must account for all of these direct consumers when it replaces the tab +with the external Growth Studio link: + +| Concern | Current consumer | +|---|---| +| Imports | `fetchGoals`, `GoalIcon`, `GoalsView`, and the `RepositoryGoal` type exist only for the Goals tab in `App.tsx`. | +| Tab type and route tables | `Tab` includes `goals`; `TAB_ROUTES.goals` is `/goals`; `ROUTE_TABS` derives the reverse mapping; `tabFromPath` therefore selects `goals` for `/goals`. | +| View selection | `tab` comes from `location.pathname`; `view` mirrors it outside Preferences, enabling the Goals render branch. | +| State | `goals` and `goalsLoaded` hold the list and initial-load state. | +| Account lifecycle | `handleBaseAccountChange` clears `goals` and resets `goalsLoaded`; `handleLogout` does the same. | +| Refresh callback | `refreshGoals` calls `fetchGoals`, replaces `goals`, and marks them loaded; it is passed to `GoalsView` as `onChange`. | +| Route-triggered loading effect | When authenticated and `tab === "goals"`, an abortable effect calls `fetchGoals`; success stores the list and all non-abort failures still mark the initial load complete. The effect reruns for auth state, active account, or tab changes. | +| Dashboard data dependency | `tab` is passed to `useDashboardData`; `dataRequirementsForTab` (in `src/utils/dataRequirements.ts`) currently maps `goals` to the repositories resource used by the picker. Top-bar refresh calls the same helper with `tab`. | +| Body class | The body-class effect toggles `tab-goals` when `tab === "goals"`. | +| Search and filters | Goals shares `repoFilters.search` with repositories, insights, alerts, and digests. `setSearch` updates `repoFilters` and resets `repoPage`; `resetFilters` restores `defaultRepoFilters`. `GoalsView` nevertheless receives the unfiltered `repos` array. | +| Generic tab propagation | `tab` is passed to `SidebarControls`; `navigateTab` and `TAB_ROUTES[tab]` are used by tab buttons, modal closing, and `CommandPalette` navigation. These generic paths continue to compile only if their tab types remain compatible after `goals` is removed. | +| Tab-strip entry | `tabs` adds the `goals` item with `tabs.goals`, `goals.length`, `goalsLoaded`, and `GoalIcon`; the shared map renders it as a `
{open ? (
-
{matches.length ? `${matches.length} repositories` : "No repositories found"}
+
+ {matches.length ? t("growth.repositoriesFound", { count: matches.length }) : t("growth.noRepositoriesFound")} +
{matches.map((repo, index) => ( ))} diff --git a/src/components/growth/GrowthSidebar.tsx b/src/components/growth/GrowthSidebar.tsx new file mode 100644 index 0000000..c905308 --- /dev/null +++ b/src/components/growth/GrowthSidebar.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; +import { NavLink } from "react-router-dom"; +import { useI18n } from "../../i18n/I18nProvider"; +import { growthRepositoryPath } from "../../utils/growthRoutes"; + +interface GrowthSidebarProps { + selectedRepository: string; + onNavigate: () => void; +} + +interface NavigationItem { + to: string; + label: string; + icon: ReactNode; + end?: boolean; +} + +export function GrowthSidebar({ selectedRepository, onNavigate }: GrowthSidebarProps) { + const { t } = useI18n(); + const workspaceBase = selectedRepository ? growthRepositoryPath(selectedRepository) : null; + + const globalItems: NavigationItem[] = [ + { to: "/growth", label: t("growth.home"), icon: , end: true }, + { to: "/growth/calendar", label: t("growth.unifiedCalendar"), icon: }, + { to: "/growth/review", label: t("growth.review"), icon: }, + { to: "/growth/settings", label: t("growth.settings"), icon: }, + ]; + + const workspaceItems: NavigationItem[] = workspaceBase ? [ + { to: workspaceBase, label: t("growth.overview"), icon: , end: true }, + { to: `${workspaceBase}/missions`, label: t("growth.missions"), icon: }, + { to: `${workspaceBase}/interventions`, label: t("growth.interventions"), icon: }, + { to: `${workspaceBase}/calendar`, label: t("growth.calendar"), icon: }, + { to: `${workspaceBase}/library`, label: t("growth.library"), icon: }, + { to: `${workspaceBase}/review`, label: t("growth.review"), icon: }, + ] : []; + + return ( + + ); +} + +function GrowthNavLink({ item, onNavigate }: { item: NavigationItem; onNavigate: () => void }) { + return ( + `growth-navigation-link${isActive ? " active" : ""}`} + onClick={onNavigate} + > + + {item.label} + + ); +} + +function Icon({ children }: { children: ReactNode }) { + return ( + + ); +} + +function HomeIcon() { + return ; +} + +function CalendarIcon() { + return ; +} + +function ReviewIcon() { + return ; +} + +function SettingsIcon() { + return ; +} + +function OverviewIcon() { + return ; +} + +function MissionsIcon() { + return ; +} + +function InterventionsIcon() { + return ; +} + +function LibraryIcon() { + return ; +} diff --git a/src/components/growth/GrowthStudioApp.tsx b/src/components/growth/GrowthStudioApp.tsx index 5abcc1f..09789cd 100644 --- a/src/components/growth/GrowthStudioApp.tsx +++ b/src/components/growth/GrowthStudioApp.tsx @@ -1,7 +1,253 @@ +import { useEffect, useState } from "react"; +import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { + AuthRequiredClientError, + fetchAuthStatus, + fetchRepos, + logoutAuth, + type AuthMode, +} from "../../api/github"; +import { invalidate as invalidateClientCache } from "../../api/cache"; +import { useAccounts } from "../../contexts/AccountContext"; import { useI18n } from "../../i18n/I18nProvider"; +import type { GhRepo } from "../../types/github"; +import { + growthRepositorySwitchPath, + parseGrowthWorkspacePath, +} from "../../utils/growthRoutes"; +import { AuthGate } from "../AuthGate"; +import { GrowthSidebar } from "./GrowthSidebar"; +import { GrowthTopBar, type GrowthTheme } from "./GrowthTopBar"; + +type AuthState = "checking" | "anonymous" | "authenticated"; +type GrowthPanelKey = + | "growth.home" + | "growth.unifiedCalendar" + | "growth.review" + | "growth.settings" + | "growth.overview" + | "growth.missions" + | "growth.interventions" + | "growth.calendar" + | "growth.library"; + +const DASHBOARD_BODY_CLASSES = [ + "filters-open", + "route-preferences", + "tab-inbox", + "tab-issues", + "tab-prs", + "tab-repos", + "tab-kanban", + "tab-insights", + "tab-alerts", + "tab-ci", + "tab-digests", + "tab-goals", +]; + +function initialTheme(): GrowthTheme { + const stored = localStorage.getItem("gh-dash.theme"); + return stored === "light" || stored === "auto" ? stored : "dark"; +} export function GrowthStudioApp() { const { t } = useI18n(); + const { active: activeAccount, loading: accountsLoading, refresh: refreshAccounts } = useAccounts(); + const location = useLocation(); + const navigate = useNavigate(); + const workspaceRoute = parseGrowthWorkspacePath(location.pathname); + const selectedRepository = workspaceRoute?.repository ?? ""; + const [authState, setAuthState] = useState("checking"); + const [authLogin, setAuthLogin] = useState(null); + const [authMode, setAuthMode] = useState("device"); + const [repos, setRepos] = useState([]); + const [repositoriesLoading, setRepositoriesLoading] = useState(false); + const [repositoryError, setRepositoryError] = useState(""); + const [repositoryLoadKey, setRepositoryLoadKey] = useState(0); + const [theme, setTheme] = useState(initialTheme); + const [navigationOpen, setNavigationOpen] = useState(false); + + useEffect(() => { + document.body.classList.add("mode-growth"); + document.body.classList.remove(...DASHBOARD_BODY_CLASSES); + return () => { + document.body.classList.remove("mode-growth"); + }; + }, []); + + useEffect(() => { + let mounted = true; + void fetchAuthStatus() + .then((status) => { + if (!mounted) return; + setAuthMode(status.mode); + if (status.authenticated) { + setAuthLogin(status.login); + setAuthState("authenticated"); + } else { + setAuthState("anonymous"); + } + }) + .catch(() => { + if (mounted) setAuthState("anonymous"); + }); + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + localStorage.setItem("gh-dash.theme", theme); + }, [theme]); + + useEffect(() => { + if (!navigationOpen) return; + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setNavigationOpen(false); + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [navigationOpen]); + + useEffect(() => { + if (authState !== "authenticated" || accountsLoading) return; + const controller = new AbortController(); + setRepos([]); + setRepositoryError(""); + setRepositoriesLoading(true); + void fetchRepos(false, controller.signal) + .then((data) => { + if (!controller.signal.aborted) setRepos(data.repos); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + if (error instanceof AuthRequiredClientError) { + setAuthLogin(null); + setAuthState("anonymous"); + return; + } + if ((error as Error).name !== "AbortError") setRepositoryError((error as Error).message); + }) + .finally(() => { + if (!controller.signal.aborted) setRepositoriesLoading(false); + }); + return () => controller.abort(); + }, [authState, accountsLoading, activeAccount?.id, repositoryLoadKey]); + + function handleRepositoryChange(repository: string) { + if (!repository) return; + const destination = growthRepositorySwitchPath(repository, location.pathname); + if (destination) navigate(destination); + } - return

{t("goals.growthStudio")}

; + function handleAccountChange() { + setRepos([]); + setRepositoryError(""); + setRepositoryLoadKey((key) => key + 1); + navigate("/growth"); + } + + async function handleLogout() { + setRepos([]); + setRepositoryError(""); + try { + await logoutAuth(); + } catch { + // The local shell still returns to authentication when logout fails. + } + invalidateClientCache(); + setAuthLogin(null); + setAuthState("anonymous"); + navigate("/growth", { replace: true }); + } + + if (authState === "checking") { + return ( +
+

{t("common.loadingEllipsis")}

+
+ ); + } + + if (authState === "anonymous") { + return ( + { + setAuthLogin(login); + setAuthState("authenticated"); + void refreshAccounts(); + }} + /> + ); + } + + return ( +
+ setNavigationOpen(true)} + onRepositoryChange={handleRepositoryChange} + onThemeChange={() => setTheme(theme === "dark" ? "light" : theme === "light" ? "auto" : "dark")} + onAccountChange={handleAccountChange} + onLogout={() => void handleLogout()} + /> +
+ ); +} + +function WorkspacePlaceholder({ titleKey }: { titleKey: GrowthPanelKey }) { + const location = useLocation(); + const route = parseGrowthWorkspacePath(location.pathname); + if (!route) return ; + return ; +} + +function GrowthPlaceholder({ titleKey, repository }: { titleKey: GrowthPanelKey; repository?: string }) { + const { t } = useI18n(); + return ( +
+ + {repository ? t("growth.repositoryWorkspace") : t("growth.growthStudio")} + +

{t(titleKey)}

+ {repository ?

{repository}

: null} +

{t("growth.placeholderDescription")}

+
+ ); } diff --git a/src/components/growth/GrowthTopBar.tsx b/src/components/growth/GrowthTopBar.tsx new file mode 100644 index 0000000..55b4b72 --- /dev/null +++ b/src/components/growth/GrowthTopBar.tsx @@ -0,0 +1,98 @@ +import appLogo from "../../assets/app-logo-mark.svg"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { GhRepo } from "../../types/github"; +import { AccountSwitcher } from "../AccountSwitcher"; +import { RepositoryPicker } from "../common/RepositoryPicker"; + +export type GrowthTheme = "dark" | "light" | "auto"; + +interface GrowthTopBarProps { + repos: GhRepo[]; + selectedRepository: string; + repositoriesLoading: boolean; + theme: GrowthTheme; + authLogin: string | null; + canLogout: boolean; + onOpenNavigation: () => void; + onRepositoryChange: (repository: string) => void; + onThemeChange: () => void; + onAccountChange: () => void; + onLogout: () => void; +} + +export function GrowthTopBar({ + repos, + selectedRepository, + repositoriesLoading, + theme, + authLogin, + canLogout, + onOpenNavigation, + onRepositoryChange, + onThemeChange, + onAccountChange, + onLogout, +}: GrowthTopBarProps) { + const { t } = useI18n(); + const themeIcon = theme === "dark" ? "☾" : theme === "light" ? "☀" : "◐"; + + return ( +
+
+ + + + {t("growth.productName")} + {t("growth.productTagline")} + +
+ +
+ {t("growth.repository")} + +
+ +
+ + +
+
+ ); +} + +function MenuIcon() { + return ( + + ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 08863ce..6bef624 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -147,6 +147,34 @@ export const en = { "tabs.digest": "Digest", "tabs.board": "Board", "tabs.goals": "Goals", + "growth.productName": "Growth Studio", + "growth.productTagline": "Repository growth workspace", + "growth.growthStudio": "Growth Studio", + "growth.navigation": "Growth navigation", + "growth.globalNavigation": "Growth Studio", + "growth.repositoryNavigation": "Repository workspace", + "growth.openNavigation": "Open navigation", + "growth.closeNavigation": "Close navigation", + "growth.repository": "Repository", + "growth.selectRepository": "Select a repository…", + "growth.loadingRepositories": "Loading repositories…", + "growth.repositoriesFound": "{count} repositories", + "growth.noRepositoriesFound": "No repositories found", + "growth.noRepositoryDescription": "No description", + "growth.repositoryLoadError": "Could not load repositories: {message}", + "growth.home": "Home", + "growth.unifiedCalendar": "Unified calendar", + "growth.review": "Review", + "growth.settings": "Settings", + "growth.workspace": "Workspace", + "growth.overview": "Overview", + "growth.missions": "Missions", + "growth.interventions": "Interventions", + "growth.calendar": "Calendar", + "growth.library": "Library", + "growth.noRepositorySelected": "Select a repository to open its workspace.", + "growth.repositoryWorkspace": "Repository workspace", + "growth.placeholderDescription": "This area is ready for its dedicated Growth Studio panel.", "goals.createTitle": "Set a repository goal", "goals.createDescription": "Track a measurable result and get an action plan based on repository activity.", "goals.repository": "Repository", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 9ff6f54..7903cba 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -149,6 +149,34 @@ export const it: Record = { "tabs.digest": "Digest", "tabs.board": "Board", "tabs.goals": "Obiettivi", + "growth.productName": "Growth Studio", + "growth.productTagline": "Spazio di crescita per repository", + "growth.growthStudio": "Growth Studio", + "growth.navigation": "Navigazione Growth", + "growth.globalNavigation": "Growth Studio", + "growth.repositoryNavigation": "Spazio della repository", + "growth.openNavigation": "Apri navigazione", + "growth.closeNavigation": "Chiudi navigazione", + "growth.repository": "Repository", + "growth.selectRepository": "Seleziona una repository…", + "growth.loadingRepositories": "Caricamento repository…", + "growth.repositoriesFound": "{count} repository", + "growth.noRepositoriesFound": "Nessuna repository trovata", + "growth.noRepositoryDescription": "Nessuna descrizione", + "growth.repositoryLoadError": "Impossibile caricare le repository: {message}", + "growth.home": "Home", + "growth.unifiedCalendar": "Calendario unificato", + "growth.review": "Revisione", + "growth.settings": "Impostazioni", + "growth.workspace": "Spazio di lavoro", + "growth.overview": "Panoramica", + "growth.missions": "Missioni", + "growth.interventions": "Interventi", + "growth.calendar": "Calendario", + "growth.library": "Libreria", + "growth.noRepositorySelected": "Seleziona una repository per aprire il suo spazio di lavoro.", + "growth.repositoryWorkspace": "Spazio della repository", + "growth.placeholderDescription": "Quest’area è pronta per il suo pannello Growth Studio dedicato.", "goals.createTitle": "Imposta un obiettivo per la repository", "goals.createDescription": "Monitora un risultato misurabile e ricevi un piano basato sull'attività della repository.", "goals.repository": "Repository", diff --git a/src/styles.css b/src/styles.css index 6454f54..76077b5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -10,3 +10,4 @@ @import "./styles/footer.css"; @import "./styles/preferences.css"; @import "./styles/goals.css"; +@import "./styles/growth/shell.css"; diff --git a/src/styles/growth/shell.css b/src/styles/growth/shell.css new file mode 100644 index 0000000..79d6bce --- /dev/null +++ b/src/styles/growth/shell.css @@ -0,0 +1,433 @@ +body.mode-growth { + min-height: 100vh; + background: var(--bg-grad); +} + +.mode-growth .growth-shell { + min-height: 100vh; + color: var(--text); +} + +.mode-growth .growth-topbar { + position: sticky; + top: 0; + z-index: 60; + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(280px, 520px) minmax(220px, 1fr); + align-items: center; + gap: 20px; + min-height: 72px; + padding: 12px clamp(18px, 2.5vw, 40px); + background: color-mix(in srgb, var(--topbar-bg) 94%, var(--panel)); + border-bottom: 1px solid var(--border-soft); + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.16); + backdrop-filter: saturate(150%) blur(16px); + -webkit-backdrop-filter: saturate(150%) blur(16px); +} + +.mode-growth .growth-brand, +.mode-growth .growth-topbar-actions { + display: flex; + align-items: center; + min-width: 0; +} + +.mode-growth .growth-brand { + gap: 11px; +} + +.mode-growth .growth-brand-logo { + display: grid; + flex: 0 0 auto; + width: 40px; + height: 40px; + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: 11px; + background: var(--panel-2); + box-shadow: 0 10px 26px var(--accent-shadow); +} + +.mode-growth .growth-brand-logo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.mode-growth .growth-brand-copy { + display: grid; + min-width: 0; + gap: 2px; +} + +.mode-growth .growth-brand-copy strong, +.mode-growth .growth-brand-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mode-growth .growth-brand-copy strong { + font-size: 15px; + letter-spacing: 0.01em; +} + +.mode-growth .growth-brand-copy small, +.mode-growth .growth-repository-label { + color: var(--muted); + font-size: 11px; +} + +.mode-growth .growth-repository-control { + display: grid; + min-width: 0; + gap: 4px; +} + +.mode-growth .growth-repository-label { + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.mode-growth .growth-repository-control .repository-picker-input { + min-height: 38px; + background: color-mix(in srgb, var(--panel-2) 88%, transparent); +} + +.mode-growth .growth-repository-control .repository-picker-menu { + width: max(100%, 430px); +} + +.mode-growth .growth-topbar-actions { + justify-content: flex-end; + gap: 6px; +} + +.mode-growth .growth-theme-toggle { + border-color: var(--border-soft); + background: color-mix(in srgb, var(--panel) 78%, transparent); +} + +.mode-growth .growth-navigation-toggle { + display: none; + place-items: center; + width: 36px; + height: 36px; + flex: 0 0 auto; + padding: 0; + color: var(--text); + background: transparent; + border: 1px solid var(--border-soft); + border-radius: 8px; + cursor: pointer; +} + +.mode-growth .growth-navigation-toggle:hover, +.mode-growth .growth-navigation-toggle:focus-visible { + background: var(--panel-2); + border-color: var(--button-hover-border); +} + +.mode-growth .growth-shell-layout { + display: grid; + grid-template-columns: 250px minmax(0, 1fr); + gap: clamp(18px, 2vw, 30px); + width: 100%; + padding: 24px clamp(18px, 2.5vw, 40px) 48px; +} + +.mode-growth .growth-sidebar { + position: sticky; + top: 96px; + align-self: start; + max-height: calc(100vh - 120px); + overflow-y: auto; + padding: 8px; + background: color-mix(in srgb, var(--panel) 95%, transparent); + border: 1px solid var(--border-soft); + border-radius: 12px; + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.14); +} + +.mode-growth .growth-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 10px 8px; + color: var(--muted); + font-size: 10.5px; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.mode-growth .growth-sidebar-header button { + display: none; + width: 28px; + height: 28px; + padding: 0; + color: var(--muted); + background: transparent; + border: 0; + border-radius: 6px; + font-size: 20px; + cursor: pointer; +} + +.mode-growth .growth-navigation { + display: grid; + gap: 3px; +} + +.mode-growth .growth-navigation-link { + display: flex; + align-items: center; + gap: 10px; + min-height: 39px; + padding: 8px 10px; + color: var(--muted); + border: 1px solid transparent; + border-radius: 8px; + font-size: 12.5px; + font-weight: 650; + transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} + +.mode-growth .growth-navigation-link:hover { + color: var(--text); + background: var(--hover-surface); + text-decoration: none; +} + +.mode-growth .growth-navigation-link.active { + color: var(--active-text); + background: linear-gradient(180deg, var(--active-start), var(--active-end)); + border-color: var(--border); + box-shadow: inset 2px 0 var(--accent), 0 7px 18px rgba(0, 0, 0, 0.12); +} + +.mode-growth .growth-navigation-icon { + display: grid; + flex: 0 0 auto; + place-items: center; + width: 20px; + color: var(--muted-2); +} + +.mode-growth .growth-navigation-link.active .growth-navigation-icon { + color: var(--accent); +} + +.mode-growth .growth-sidebar-workspace { + display: grid; + gap: 7px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); +} + +.mode-growth .growth-sidebar-section-title { + display: grid; + min-width: 0; + gap: 4px; + padding: 3px 10px 6px; + color: var(--muted-2); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.mode-growth .growth-sidebar-section-title strong { + overflow: hidden; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + letter-spacing: 0; + text-overflow: ellipsis; + text-transform: none; + white-space: nowrap; +} + +.mode-growth .growth-sidebar-empty { + margin: 0; + padding: 6px 10px 10px; + color: var(--muted); + font-size: 11.5px; + line-height: 1.5; +} + +.mode-growth .growth-main { + min-width: 0; +} + +.mode-growth .growth-error { + margin-bottom: 14px; + padding: 10px 12px; + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--border)); + border-radius: 9px; + font-size: 12px; +} + +.mode-growth .growth-placeholder { + min-height: min(540px, calc(100vh - 144px)); + padding: clamp(28px, 5vw, 64px); + background: + radial-gradient(520px 240px at 100% 0, var(--accent-faint), transparent 66%), + var(--panel); + border: 1px solid var(--border-soft); + border-radius: 14px; + box-shadow: var(--shadow); +} + +.mode-growth .growth-placeholder-eyebrow { + display: inline-flex; + margin-bottom: 14px; + color: var(--accent); + font-size: 10.5px; + font-weight: 800; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.mode-growth .growth-placeholder h1 { + margin: 0; + font-size: clamp(28px, 4vw, 46px); + line-height: 1.05; + letter-spacing: -0.035em; +} + +.mode-growth .growth-placeholder p { + max-width: 580px; + margin: 18px 0 0; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.mode-growth .growth-placeholder .growth-placeholder-repository { + margin-top: 10px; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} + +.mode-growth .growth-sidebar-backdrop { + display: none; +} + +@media (max-width: 1100px) { + .mode-growth .growth-topbar { + grid-template-columns: minmax(190px, 0.8fr) minmax(260px, 1.4fr) auto; + gap: 14px; + } + + .mode-growth .growth-theme-toggle { + width: 34px; + padding: 0; + justify-content: center; + } + + .mode-growth .growth-theme-toggle .label { + display: none; + } +} + +@media (max-width: 960px) { + .mode-growth .growth-topbar { + grid-template-columns: minmax(170px, 0.8fr) minmax(230px, 1.2fr) auto; + padding-inline: 14px; + } + + .mode-growth .growth-brand-logo { + display: none; + } + + .mode-growth .growth-navigation-toggle { + display: grid; + } + + .mode-growth .growth-shell-layout { + grid-template-columns: minmax(0, 1fr); + padding: 18px 14px 36px; + } + + .mode-growth .growth-sidebar { + position: fixed; + z-index: 80; + top: 0; + bottom: 0; + left: 0; + width: min(310px, 88vw); + max-height: none; + border-radius: 0 12px 12px 0; + box-shadow: 14px 0 42px rgba(0, 0, 0, 0.5); + transform: translateX(-105%); + transition: transform 0.22s ease; + } + + .mode-growth .navigation-open .growth-sidebar { + transform: translateX(0); + } + + .mode-growth .growth-sidebar-header button { + display: inline-grid; + place-items: center; + } + + .mode-growth .growth-sidebar-backdrop { + position: fixed; + z-index: 70; + inset: 0; + display: block; + padding: 0; + pointer-events: none; + background: rgba(0, 0, 0, 0.55); + border: 0; + opacity: 0; + transition: opacity 0.18s ease; + } + + .mode-growth .navigation-open .growth-sidebar-backdrop { + pointer-events: auto; + opacity: 1; + } +} + +@media (max-width: 700px) { + .mode-growth .growth-topbar { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + } + + .mode-growth .growth-brand-copy small, + .mode-growth .growth-repository-label { + display: none; + } + + .mode-growth .growth-repository-control { + grid-column: 1 / -1; + grid-row: 2; + } + + .mode-growth .growth-repository-control .repository-picker-menu { + width: 100%; + max-width: 100%; + } + + .mode-growth .growth-placeholder { + min-height: calc(100vh - 174px); + padding: 28px 22px; + } +} + +@media (prefers-reduced-motion: reduce) { + .mode-growth .growth-sidebar, + .mode-growth .growth-sidebar-backdrop { + transition: none; + } +} diff --git a/src/utils/growthRoutes.ts b/src/utils/growthRoutes.ts new file mode 100644 index 0000000..0339f1c --- /dev/null +++ b/src/utils/growthRoutes.ts @@ -0,0 +1,61 @@ +import { parseRepositoryName } from "./repository"; + +export const GROWTH_WORKSPACE_PANELS = [ + "missions", + "interventions", + "calendar", + "library", + "review", +] as const; + +export type GrowthWorkspacePanel = (typeof GROWTH_WORKSPACE_PANELS)[number]; + +export interface GrowthWorkspaceRoute { + repository: string; + panel: GrowthWorkspacePanel | null; +} + +const PANEL_SET = new Set(GROWTH_WORKSPACE_PANELS); +const WORKSPACE_PATH_PATTERN = /^\/growth\/r\/([^/]+)\/([^/]+)(?:\/([^/]+))?\/?$/; + +function decodePathSegment(segment: string): string | null { + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +export function parseGrowthWorkspacePath(pathname: string): GrowthWorkspaceRoute | null { + const match = WORKSPACE_PATH_PATTERN.exec(pathname); + if (!match) return null; + + const owner = decodePathSegment(match[1]); + const name = decodePathSegment(match[2]); + const panelSegment = match[3] ? decodePathSegment(match[3]) : null; + if (!owner || !name || (panelSegment !== null && !PANEL_SET.has(panelSegment))) return null; + + const repository = `${owner}/${name}`; + if (!parseRepositoryName(repository)) return null; + + return { + repository, + panel: panelSegment as GrowthWorkspacePanel | null, + }; +} + +export function growthRepositoryPath( + repository: string, + panel: GrowthWorkspacePanel | null = null, +): string | null { + const parts = parseRepositoryName(repository); + if (!parts) return null; + const [owner, name] = parts; + const base = `/growth/r/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; + return panel ? `${base}/${panel}` : base; +} + +export function growthRepositorySwitchPath(repository: string, currentPathname: string): string | null { + const currentWorkspace = parseGrowthWorkspacePath(currentPathname); + return growthRepositoryPath(repository, currentWorkspace?.panel ?? null); +} diff --git a/tests/utils/growthRoutes.test.ts b/tests/utils/growthRoutes.test.ts new file mode 100644 index 0000000..4209099 --- /dev/null +++ b/tests/utils/growthRoutes.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + growthRepositoryPath, + growthRepositorySwitchPath, + parseGrowthWorkspacePath, +} from "../../src/utils/growthRoutes"; + +describe("Growth Studio routes", () => { + it("parses validated workspace routes", () => { + expect(parseGrowthWorkspacePath("/growth/r/openai/codex")).toEqual({ + repository: "openai/codex", + panel: null, + }); + expect(parseGrowthWorkspacePath("/growth/r/openai/codex/missions/")).toEqual({ + repository: "openai/codex", + panel: "missions", + }); + }); + + it("rejects malformed repositories, panels, and extra segments", () => { + expect(parseGrowthWorkspacePath("/growth/r/openai/codex/unknown")).toBeNull(); + expect(parseGrowthWorkspacePath("/growth/r/openai/codex/review/extra")).toBeNull(); + expect(parseGrowthWorkspacePath("/growth/r/openai%2Fother/codex")).toBeNull(); + expect(parseGrowthWorkspacePath("/growth/r/openai/%E0%A4%A")).toBeNull(); + }); + + it("builds repository routes and retains a workspace panel on switch", () => { + expect(growthRepositoryPath("openai/codex")).toBe("/growth/r/openai/codex"); + expect(growthRepositoryPath("openai/codex", "calendar")).toBe("/growth/r/openai/codex/calendar"); + expect(growthRepositoryPath("invalid")).toBeNull(); + expect(growthRepositorySwitchPath("acme/docs", "/growth/r/openai/codex/library")).toBe( + "/growth/r/acme/docs/library", + ); + expect(growthRepositorySwitchPath("acme/docs", "/growth/review")).toBe("/growth/r/acme/docs"); + }); +}); From 6a7a9c091810cd09b0241b2b02a8ce9464aa3469 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 09:31:33 +0200 Subject: [PATCH 09/54] feat(growth): replace goals tab with studio entry --- .../docs/GS_FEATURE_MATRIX.md | 2 +- growth-studio-project/tasks/PROGRESS.md | 2 +- src/App.tsx | 44 ++++--------------- src/components/SidebarControls.tsx | 2 +- src/components/growth/GrowthStudioApp.tsx | 1 - src/i18n/en.ts | 1 + src/i18n/it.ts | 1 + src/main.tsx | 3 +- src/styles/navigation.css | 2 +- src/utils/dataRequirements.ts | 3 +- 10 files changed, 17 insertions(+), 44 deletions(-) diff --git a/growth-studio-project/docs/GS_FEATURE_MATRIX.md b/growth-studio-project/docs/GS_FEATURE_MATRIX.md index b08aa63..08ebbb3 100644 --- a/growth-studio-project/docs/GS_FEATURE_MATRIX.md +++ b/growth-studio-project/docs/GS_FEATURE_MATRIX.md @@ -16,7 +16,7 @@ Authoritative inventory of Growth Studio capabilities. Statuses: `EXISTING` | AI provider settings and connection test | `src/components/preferences/AiIntegrationSettings.tsx`, `src/api/github.ts`, `src/server/routes/ai.ts`, `src/server/ai/client.ts`, `src/server/ai/settings.ts`, `src/server/ai/providers.ts` | EXISTING | — | | `/growth` client routes served by the SPA | `src/server/spa.ts`, `src/main.tsx`, `src/components/growth/GrowthStudioApp.tsx`, `tests/server/spa.test.ts` | DONE | GS-010 | | Growth shell: own top bar, sidebar, `mode-growth` body class, no dashboard chrome | `src/components/growth/GrowthStudioApp.tsx`, `src/components/growth/GrowthTopBar.tsx`, `src/components/growth/GrowthSidebar.tsx`, `src/styles/growth/shell.css` | DONE | GS-011 | -| Main-menu entry opening `/growth` in a new window; `goals` tab removed; `/goals` redirect | `App.tsx` | PLANNED | GS-012 | +| Main-menu entry opening `/growth` in a new window; `goals` tab removed; `/goals` redirect | `src/App.tsx`, `src/main.tsx`, `src/utils/dataRequirements.ts`, `src/components/SidebarControls.tsx` | DONE | GS-012 | | Missions panel hosting the existing goals UI | `/growth/r/:owner/:repo/missions` | PLANNED | GS-013 | | Growth home: repositories with profiles or goals, quick stats | `/growth` | PLANNED | GS-014 | | Workspace overview per repository | `/growth/r/:owner/:repo` | PLANNED | GS-015 | diff --git a/growth-studio-project/tasks/PROGRESS.md b/growth-studio-project/tasks/PROGRESS.md index 1a34b16..8d67f8e 100644 --- a/growth-studio-project/tasks/PROGRESS.md +++ b/growth-studio-project/tasks/PROGRESS.md @@ -15,7 +15,7 @@ phase-closing tasks) are appended with `PENDING` rows and matching task files. | GS-003 | COMPLETED | Hardened the gate with fixture-free test-placement and symmetric English/Italian locale-key guards | VALIDATION OK twice; full typecheck; 28 test files and 151 tests; production build; second-run cache hit; misplaced test and both locale mismatch directions failed as expected; clean guards passed; git diff --check | 2026-09-04 | | GS-010 | COMPLETED | Added extension-safe Growth Studio SPA routes and route-aware placeholder mounting without changing dashboard routes | VALIDATION OK; typecheck; 29 test files and 156 tests; production build; dev HTTP and Chromium checks for both Growth routes and repositories | 2026-09-04 | | GS-011 | COMPLETED | Added the authenticated Growth Studio shell with dedicated top bar, responsive sidebar, repository switching, route placeholders, and shared theme handling | VALIDATION OK; typecheck; 30 test files and 159 tests; production build; Growth route utility tests; git diff --check | 2026-09-04 | -| GS-012 | PENDING | — | — | — | +| GS-012 | COMPLETED | Replaced the dashboard goals tab with a new-window Growth Studio entry and added the legacy goals redirect | VALIDATION OK; typecheck; 30 test files and 159 tests; production build; acceptance grep; git diff --check | 2026-09-04 | | GS-013 | PENDING | — | — | — | | GS-014 | PENDING | — | — | — | | GS-015 | PENDING | — | — | — | diff --git a/src/App.tsx b/src/App.tsx index 6a4083f..8593a2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ import { fetchAuthStatus, fetchCIHealth, fetchDailyDigests, - fetchGoals, fetchNotifications, fetchRepoInsights, logoutAuth, @@ -33,8 +32,6 @@ 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, @@ -71,7 +68,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" | "goals"; +type Tab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests"; type Theme = "dark" | "light" | "auto"; type TextSize = "small" | "normal" | "large"; @@ -85,7 +82,6 @@ const TAB_ROUTES: Record = { alerts: "/alerts", ci: "/ci", digests: "/daily", - goals: "/goals", }; const PREFERENCES_ROUTE = "/preferences"; @@ -196,8 +192,6 @@ 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); @@ -240,8 +234,6 @@ export function App() { setRepoInsights([]); setDailyDigests([]); setCiHealth([]); - setGoals([]); - setGoalsLoaded(false); setInsightsLoaded(false); setCiLoaded(false); setDigestsLoaded(false); @@ -293,24 +285,6 @@ 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; @@ -372,8 +346,6 @@ export function App() { setRepoInsights([]); setDailyDigests([]); setCiHealth([]); - setGoals([]); - setGoalsLoaded(false); setInsightsLoaded(false); setCiLoaded(false); setDigestsLoaded(false); @@ -406,7 +378,6 @@ 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, isPreferencesPage]); @@ -659,7 +630,7 @@ export function App() { const search = tab === "inbox" ? inboxSearch - : tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals" + : tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" ? repoFilters.search : tab === "prs" ? prFilters.search @@ -678,7 +649,7 @@ export function App() { if (tab === "inbox") { setInboxSearch(value); setInboxPage(1); - } else if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals") { + } else if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests") { setRepoFilters({ ...repoFilters, search: value }); setRepoPage(1); } else if (tab === "prs") { @@ -691,7 +662,7 @@ export function App() { } function resetFilters() { - if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals") setRepoFilters(defaultRepoFilters()); + if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests") setRepoFilters(defaultRepoFilters()); else if (tab === "prs") setPrFilters(defaultPrFilters()); else setIssueFilters(defaultIssueFilters()); clearFiltersCache(); @@ -735,7 +706,6 @@ export function App() { { 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: }] : []), @@ -811,6 +781,10 @@ export function App() { ) : null} ))} + + + {t("tabs.growthStudio")} +
) : null} @@ -1009,8 +983,6 @@ export function App() { ) : null} - {view === "goals" ? : null} - {view === "kanban" && projectsEnabled ? { setBoardCount(count); setBoardLoaded(true); }} /> : null} diff --git a/src/components/SidebarControls.tsx b/src/components/SidebarControls.tsx index 8c4443c..223bff5 100644 --- a/src/components/SidebarControls.tsx +++ b/src/components/SidebarControls.tsx @@ -6,7 +6,7 @@ import { formatNumber } from "../utils/format"; import { ChevronIcon, CloseIcon, SearchIcon } from "./common/Icons"; import { useI18n } from "../i18n/I18nProvider"; -type Tab = "inbox" | "issues" | "repos" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "prs" | "goals"; +type Tab = "inbox" | "issues" | "repos" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "prs"; export interface InboxSidebarState { mailbox: InboxMailbox; diff --git a/src/components/growth/GrowthStudioApp.tsx b/src/components/growth/GrowthStudioApp.tsx index 09789cd..c10e5cb 100644 --- a/src/components/growth/GrowthStudioApp.tsx +++ b/src/components/growth/GrowthStudioApp.tsx @@ -43,7 +43,6 @@ const DASHBOARD_BODY_CLASSES = [ "tab-alerts", "tab-ci", "tab-digests", - "tab-goals", ]; function initialTheme(): GrowthTheme { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6bef624..135629e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -147,6 +147,7 @@ export const en = { "tabs.digest": "Digest", "tabs.board": "Board", "tabs.goals": "Goals", + "tabs.growthStudio": "Growth Studio", "growth.productName": "Growth Studio", "growth.productTagline": "Repository growth workspace", "growth.growthStudio": "Growth Studio", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 7903cba..e10c6f5 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -149,6 +149,7 @@ export const it: Record = { "tabs.digest": "Digest", "tabs.board": "Board", "tabs.goals": "Obiettivi", + "tabs.growthStudio": "Growth Studio", "growth.productName": "Growth Studio", "growth.productTagline": "Spazio di crescita per repository", "growth.growthStudio": "Growth Studio", diff --git a/src/main.tsx b/src/main.tsx index ba179c9..3043981 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,5 @@ import { createRoot } from "react-dom/client"; -import { BrowserRouter, useLocation } from "react-router-dom"; +import { BrowserRouter, Navigate, useLocation } from "react-router-dom"; import { App } from "./App"; import { GrowthStudioApp } from "./components/growth/GrowthStudioApp"; import { AccountProvider } from "./contexts/AccountContext"; @@ -10,6 +10,7 @@ function RouteAwareApp() { const { pathname } = useLocation(); const isGrowthRoute = pathname === "/growth" || pathname.startsWith("/growth/"); + if (pathname === "/goals") return ; return isGrowthRoute ? : ; } diff --git a/src/styles/navigation.css b/src/styles/navigation.css index 231d88c..1c0a2ff 100644 --- a/src/styles/navigation.css +++ b/src/styles/navigation.css @@ -520,7 +520,7 @@ transition: color .15s ease, background .15s ease; white-space: nowrap; } - .tab:hover { color: var(--text); background: var(--hover-surface); } + .tab:hover { color: var(--text); background: var(--hover-surface); text-decoration: none; } .tab.active { color: var(--text); background: linear-gradient(180deg, var(--panel-3), var(--panel-2)); diff --git a/src/utils/dataRequirements.ts b/src/utils/dataRequirements.ts index fd5c4ef..1969329 100644 --- a/src/utils/dataRequirements.ts +++ b/src/utils/dataRequirements.ts @@ -1,4 +1,4 @@ -export type DashboardTab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "goals"; +export type DashboardTab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests"; export type DashboardResource = "repos" | "issues" | "prs"; @@ -21,7 +21,6 @@ export function dataRequirementsForTab( resources.add("prs"); break; case "repos": - case "goals": resources.add("repos"); break; case "insights": From 0a2a943df2b00321da086c02f52ca99c28a64843 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 09:37:04 +0200 Subject: [PATCH 10/54] feat(growth): add repository missions panel --- .../docs/GS_FEATURE_MATRIX.md | 2 +- growth-studio-project/tasks/PROGRESS.md | 2 +- src/components/growth/GrowthStudioApp.tsx | 46 ++++++++- src/components/views/GoalsView.tsx | 51 +++++++--- src/hooks/useGoals.ts | 77 +++++++++++++++ src/i18n/en.ts | 1 + src/i18n/it.ts | 1 + tests/hooks/useGoals.test.ts | 98 +++++++++++++++++++ 8 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 src/hooks/useGoals.ts create mode 100644 tests/hooks/useGoals.test.ts diff --git a/growth-studio-project/docs/GS_FEATURE_MATRIX.md b/growth-studio-project/docs/GS_FEATURE_MATRIX.md index 08ebbb3..4608496 100644 --- a/growth-studio-project/docs/GS_FEATURE_MATRIX.md +++ b/growth-studio-project/docs/GS_FEATURE_MATRIX.md @@ -17,7 +17,7 @@ Authoritative inventory of Growth Studio capabilities. Statuses: `EXISTING` | `/growth` client routes served by the SPA | `src/server/spa.ts`, `src/main.tsx`, `src/components/growth/GrowthStudioApp.tsx`, `tests/server/spa.test.ts` | DONE | GS-010 | | Growth shell: own top bar, sidebar, `mode-growth` body class, no dashboard chrome | `src/components/growth/GrowthStudioApp.tsx`, `src/components/growth/GrowthTopBar.tsx`, `src/components/growth/GrowthSidebar.tsx`, `src/styles/growth/shell.css` | DONE | GS-011 | | Main-menu entry opening `/growth` in a new window; `goals` tab removed; `/goals` redirect | `src/App.tsx`, `src/main.tsx`, `src/utils/dataRequirements.ts`, `src/components/SidebarControls.tsx` | DONE | GS-012 | -| Missions panel hosting the existing goals UI | `/growth/r/:owner/:repo/missions` | PLANNED | GS-013 | +| Missions panel hosting the existing goals UI | `src/components/growth/GrowthStudioApp.tsx`, `src/hooks/useGoals.ts`, `src/components/views/GoalsView.tsx` | DONE | GS-013 | | Growth home: repositories with profiles or goals, quick stats | `/growth` | PLANNED | GS-014 | | Workspace overview per repository | `/growth/r/:owner/:repo` | PLANNED | GS-015 | | Shell i18n, responsive layout, theme parity | shell components | PLANNED | GS-016 | diff --git a/growth-studio-project/tasks/PROGRESS.md b/growth-studio-project/tasks/PROGRESS.md index 8d67f8e..c3976cf 100644 --- a/growth-studio-project/tasks/PROGRESS.md +++ b/growth-studio-project/tasks/PROGRESS.md @@ -16,7 +16,7 @@ phase-closing tasks) are appended with `PENDING` rows and matching task files. | GS-010 | COMPLETED | Added extension-safe Growth Studio SPA routes and route-aware placeholder mounting without changing dashboard routes | VALIDATION OK; typecheck; 29 test files and 156 tests; production build; dev HTTP and Chromium checks for both Growth routes and repositories | 2026-09-04 | | GS-011 | COMPLETED | Added the authenticated Growth Studio shell with dedicated top bar, responsive sidebar, repository switching, route placeholders, and shared theme handling | VALIDATION OK; typecheck; 30 test files and 159 tests; production build; Growth route utility tests; git diff --check | 2026-09-04 | | GS-012 | COMPLETED | Replaced the dashboard goals tab with a new-window Growth Studio entry and added the legacy goals redirect | VALIDATION OK; typecheck; 30 test files and 159 tests; production build; acceptance grep; git diff --check | 2026-09-04 | -| GS-013 | PENDING | — | — | — | +| GS-013 | COMPLETED | Added the repository-scoped Missions panel with locked goal creation, refreshable account-aware loading, and new-window AI preferences | VALIDATION OK; typecheck; 31 test files and 161 tests; production build; useGoals cancellation and refresh tests; git diff --check | 2026-09-04 | | GS-014 | PENDING | — | — | — | | GS-015 | PENDING | — | — | — | | GS-016 | PENDING | — | — | — | diff --git a/src/components/growth/GrowthStudioApp.tsx b/src/components/growth/GrowthStudioApp.tsx index c10e5cb..ffd532b 100644 --- a/src/components/growth/GrowthStudioApp.tsx +++ b/src/components/growth/GrowthStudioApp.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { AuthRequiredClientError, @@ -10,12 +10,14 @@ import { import { invalidate as invalidateClientCache } from "../../api/cache"; import { useAccounts } from "../../contexts/AccountContext"; import { useI18n } from "../../i18n/I18nProvider"; +import { useGoals } from "../../hooks/useGoals"; import type { GhRepo } from "../../types/github"; import { growthRepositorySwitchPath, parseGrowthWorkspacePath, } from "../../utils/growthRoutes"; import { AuthGate } from "../AuthGate"; +import { GoalsView } from "../views/GoalsView"; import { GrowthSidebar } from "./GrowthSidebar"; import { GrowthTopBar, type GrowthTheme } from "./GrowthTopBar"; @@ -217,7 +219,18 @@ export function GrowthStudioApp() { } /> } /> } /> - } /> + + )} + /> } /> } /> } /> @@ -230,6 +243,35 @@ export function GrowthStudioApp() { ); } +interface WorkspaceMissionsProps { + accountId: string | null; + enabled: boolean; + repository: string; + repos: GhRepo[]; +} + +function WorkspaceMissions({ accountId, enabled, repository, repos }: WorkspaceMissionsProps) { + const { t } = useI18n(); + const { goals, loading, error, refresh } = useGoals({ accountId, enabled, repository }); + const scopedRepos = useMemo( + () => repos.filter((repo) => repo.nameWithOwner === repository), + [repository, repos], + ); + + if (!repository) return ; + + return ( + + ); +} + function WorkspacePlaceholder({ titleKey }: { titleKey: GrowthPanelKey }) { const location = useLocation(); const route = parseGrowthWorkspacePath(location.pathname); diff --git a/src/components/views/GoalsView.tsx b/src/components/views/GoalsView.tsx index 2a92075..d699e9a 100644 --- a/src/components/views/GoalsView.tsx +++ b/src/components/views/GoalsView.tsx @@ -19,6 +19,8 @@ interface GoalsViewProps { repos: GhRepo[]; loading: boolean; onChange: () => Promise | void; + fixedRepository?: string; + loadError?: string; } const metricLabels = new Map(GOAL_METRIC_DEFINITIONS.map((metric) => [metric.id, metric.label])); @@ -29,7 +31,7 @@ function currentRepoValue(repo: GhRepo | undefined, metric: GoalMetric): number return 0; } -export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) { +export function GoalsView({ goals, repos, loading, onChange, fixedRepository, loadError = "" }: GoalsViewProps) { const { t } = useI18n(); const [repository, setRepository] = useState(""); const [metric, setMetric] = useState("stars"); @@ -43,8 +45,17 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) { // Proposals fetched while the modal is open, so reopening it shows them without a round-trip. const [proposalCache, setProposalCache] = useState>({}); const navigate = useNavigate(); - const reposByName = useMemo(() => new Map(repos.map((repo) => [repo.nameWithOwner, repo])), [repos]); - const groupedGoals = useMemo(() => groupGoalsByRepository(goals), [goals]); + const activeRepository = fixedRepository ?? repository; + const scopedRepos = useMemo( + () => fixedRepository ? repos.filter((repo) => repo.nameWithOwner === fixedRepository) : repos, + [fixedRepository, repos], + ); + const scopedGoals = useMemo( + () => fixedRepository ? goals.filter((goal) => goal.repository === fixedRepository) : goals, + [fixedRepository, goals], + ); + const reposByName = useMemo(() => new Map(scopedRepos.map((repo) => [repo.nameWithOwner, repo])), [scopedRepos]); + const groupedGoals = useMemo(() => groupGoalsByRepository(scopedGoals), [scopedGoals]); async function submit(event: FormEvent) { event.preventDefault(); @@ -52,10 +63,10 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) { setSaving(true); try { await createGoal({ - repository, + repository: activeRepository, metric, targetValue: Number(targetValue), - currentValue: currentRepoValue(reposByName.get(repository), metric), + currentValue: currentRepoValue(reposByName.get(activeRepository), metric), deadline, }); setTargetValue(""); @@ -90,6 +101,16 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) { } } + function openAiPreferences() { + setProposalTarget(null); + if (fixedRepository) { + const preferencesWindow = window.open("/preferences#preferences-ai", "_blank", "noopener"); + if (preferencesWindow) preferencesWindow.opener = null; + return; + } + navigate("/preferences#preferences-ai"); + } + return (
@@ -103,7 +124,11 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) {
void submit(event)}> - +
- {error ?
{error}
: null} - {loading && !goals.length ? : null} - {!goals.length && !loading ?

{t("goals.emptyTitle")}

{t("goals.emptyText")}

: null} + {error || loadError ?
{error || loadError}
: null} + {loading && !scopedGoals.length ? : null} + {!scopedGoals.length && !loading && !loadError ?

{t("goals.emptyTitle")}

{t("goals.emptyText")}

: null}
{groupedGoals.map((group) => { const repo = reposByName.get(group.repository); @@ -181,7 +206,7 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) {
{t("goals.growthStudioEyebrow")}

{t("goals.growthStudio")}

{t("goals.growthStudioDescription")}

- +
@@ -241,7 +266,7 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) { }} /> {proposalTarget ? (() => { - const goal = goals.find((entry) => entry.id === proposalTarget.goalId); + const goal = scopedGoals.find((entry) => entry.id === proposalTarget.goalId); const suggestion = goal?.suggestions[proposalTarget.index]; if (!goal || !suggestion) return null; const cached = proposalCache[`${goal.id}:${proposalTarget.index}`]; @@ -251,7 +276,7 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) { suggestion={cached ? { ...suggestion, proposals: cached.proposals, proposalsGeneratedAt: cached.generatedAt } : suggestion} suggestionIndex={proposalTarget.index} onClose={() => setProposalTarget(null)} - onOpenPreferences={() => { setProposalTarget(null); navigate("/preferences#preferences-ai"); }} + onOpenPreferences={openAiPreferences} onProposals={(proposals, generatedAt) => setProposalCache((prev) => ({ ...prev, [`${goal.id}:${proposalTarget.index}`]: { proposals, generatedAt } }))} /> ); diff --git a/src/hooks/useGoals.ts b/src/hooks/useGoals.ts new file mode 100644 index 0000000..9c248ff --- /dev/null +++ b/src/hooks/useGoals.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchGoals } from "../api/github"; +import type { RepositoryGoal } from "../types/goals"; + +interface UseGoalsOptions { + accountId: string | null; + enabled: boolean; + repository: string; +} + +export interface GoalsState { + goals: RepositoryGoal[]; + loading: boolean; + error: string; + refresh: () => Promise; +} + +/** Loads the goals for one repository and cancels stale account or route requests. */ +export function useGoals({ accountId, enabled, repository }: UseGoalsOptions): GoalsState { + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(enabled); + const [error, setError] = useState(""); + const [loadedKey, setLoadedKey] = useState(""); + const requestRef = useRef(null); + const requestKey = JSON.stringify([accountId, repository]); + + const refresh = useCallback(async () => { + if (!enabled || !repository) return; + + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + setLoading(true); + setError(""); + + try { + const result = await fetchGoals(controller.signal); + if (!controller.signal.aborted && requestRef.current === controller) { + setGoals(result.goals.filter((goal) => goal.repository === repository)); + } + } catch (cause) { + if (!controller.signal.aborted && (cause as Error).name !== "AbortError" && requestRef.current === controller) { + setError((cause as Error).message); + } + } finally { + if (requestRef.current === controller) { + requestRef.current = null; + setLoadedKey(requestKey); + setLoading(false); + } + } + }, [enabled, repository, requestKey]); + + useEffect(() => { + requestRef.current?.abort(); + requestRef.current = null; + setGoals([]); + setError(""); + if (!enabled || !repository) { + setLoading(false); + return; + } + + void refresh(); + return () => { + requestRef.current?.abort(); + requestRef.current = null; + }; + }, [enabled, repository, refresh]); + + return { + goals, + loading: loading || Boolean(enabled && repository && loadedKey !== requestKey), + error, + refresh, + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 135629e..84f9fb9 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -163,6 +163,7 @@ export const en = { "growth.noRepositoriesFound": "No repositories found", "growth.noRepositoryDescription": "No description", "growth.repositoryLoadError": "Could not load repositories: {message}", + "growth.goalsLoadError": "Could not load missions: {message}", "growth.home": "Home", "growth.unifiedCalendar": "Unified calendar", "growth.review": "Review", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index e10c6f5..ee48275 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -165,6 +165,7 @@ export const it: Record = { "growth.noRepositoriesFound": "Nessuna repository trovata", "growth.noRepositoryDescription": "Nessuna descrizione", "growth.repositoryLoadError": "Impossibile caricare le repository: {message}", + "growth.goalsLoadError": "Impossibile caricare le missioni: {message}", "growth.home": "Home", "growth.unifiedCalendar": "Calendario unificato", "growth.review": "Revisione", diff --git a/tests/hooks/useGoals.test.ts b/tests/hooks/useGoals.test.ts new file mode 100644 index 0000000..12e1a12 --- /dev/null +++ b/tests/hooks/useGoals.test.ts @@ -0,0 +1,98 @@ +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useGoals, type GoalsState } from "../../src/hooks/useGoals"; +import type { RepositoryGoal } from "../../src/types/goals"; + +const api = vi.hoisted(() => ({ + fetchGoals: vi.fn(), +})); + +vi.mock("../../src/api/github", () => ({ + fetchGoals: api.fetchGoals, +})); + +interface HarnessProps { + accountId: string; + repository: string; +} + +let latest: GoalsState; +let root: Root; +let container: HTMLDivElement; + +function goal(id: string, repository: string): RepositoryGoal { + return { + id, + accountId: "account-a", + repository, + metric: "stars", + targetValue: 100, + currentValue: 50, + deadline: "2026-12-31", + createdAt: "2026-09-04T00:00:00.000Z", + updatedAt: "2026-09-04T00:00:00.000Z", + suggestions: [], + suggestionsGeneratedAt: null, + aiEnabled: true, + }; +} + +function Harness({ accountId, repository }: HarnessProps) { + latest = useGoals({ accountId, enabled: true, repository }); + return null; +} + +async function render(props: HarnessProps) { + await act(async () => { + root.render(createElement(Harness, props)); + await Promise.resolve(); + }); +} + +beforeEach(() => { + api.fetchGoals.mockReset(); + container = document.createElement("div"); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); +}); + +describe("useGoals", () => { + it("loads only goals for the selected repository and supports refresh", async () => { + api.fetchGoals + .mockResolvedValueOnce({ ok: true, goals: [goal("one", "owner/one"), goal("two", "owner/two")] }) + .mockResolvedValueOnce({ ok: true, goals: [goal("three", "owner/one")] }); + + await render({ accountId: "account-a", repository: "owner/one" }); + + expect(latest.goals.map((entry) => entry.id)).toEqual(["one"]); + expect(latest.loading).toBe(false); + expect(api.fetchGoals).toHaveBeenCalledWith(expect.any(AbortSignal)); + + await act(async () => latest.refresh()); + expect(latest.goals.map((entry) => entry.id)).toEqual(["three"]); + }); + + it("aborts and ignores a stale request when the active account changes", async () => { + let oldSignal: AbortSignal | undefined; + let resolveOld!: (value: { ok: true; goals: RepositoryGoal[] }) => void; + api.fetchGoals + .mockImplementationOnce((signal?: AbortSignal) => { + oldSignal = signal; + return new Promise((resolve) => { resolveOld = resolve; }); + }) + .mockResolvedValueOnce({ ok: true, goals: [goal("new", "owner/one")] }); + + await render({ accountId: "account-a", repository: "owner/one" }); + await render({ accountId: "account-b", repository: "owner/one" }); + + expect(oldSignal?.aborted).toBe(true); + expect(latest.goals.map((entry) => entry.id)).toEqual(["new"]); + + await act(async () => resolveOld({ ok: true, goals: [goal("old", "owner/one")] })); + expect(latest.goals.map((entry) => entry.id)).toEqual(["new"]); + }); +}); From 6896434ad74824444131294e5881166b57ae825e Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 09:44:36 +0200 Subject: [PATCH 11/54] feat(growth): add repository growth home --- .../docs/GS_FEATURE_MATRIX.md | 2 +- growth-studio-project/tasks/PROGRESS.md | 2 +- src/components/growth/GrowthHome.tsx | 140 ++++++++ src/components/growth/GrowthStudioApp.tsx | 14 +- src/hooks/useGoals.ts | 24 +- src/i18n/en.ts | 22 ++ src/i18n/it.ts | 22 ++ src/styles.css | 1 + src/styles/growth/home.css | 337 ++++++++++++++++++ src/utils/growthHome.ts | 39 ++ tests/hooks/useGoals.test.ts | 14 +- tests/utils/growthHome.test.ts | 75 ++++ 12 files changed, 678 insertions(+), 14 deletions(-) create mode 100644 src/components/growth/GrowthHome.tsx create mode 100644 src/styles/growth/home.css create mode 100644 src/utils/growthHome.ts create mode 100644 tests/utils/growthHome.test.ts diff --git a/growth-studio-project/docs/GS_FEATURE_MATRIX.md b/growth-studio-project/docs/GS_FEATURE_MATRIX.md index 4608496..d9f43d1 100644 --- a/growth-studio-project/docs/GS_FEATURE_MATRIX.md +++ b/growth-studio-project/docs/GS_FEATURE_MATRIX.md @@ -18,7 +18,7 @@ Authoritative inventory of Growth Studio capabilities. Statuses: `EXISTING` | Growth shell: own top bar, sidebar, `mode-growth` body class, no dashboard chrome | `src/components/growth/GrowthStudioApp.tsx`, `src/components/growth/GrowthTopBar.tsx`, `src/components/growth/GrowthSidebar.tsx`, `src/styles/growth/shell.css` | DONE | GS-011 | | Main-menu entry opening `/growth` in a new window; `goals` tab removed; `/goals` redirect | `src/App.tsx`, `src/main.tsx`, `src/utils/dataRequirements.ts`, `src/components/SidebarControls.tsx` | DONE | GS-012 | | Missions panel hosting the existing goals UI | `src/components/growth/GrowthStudioApp.tsx`, `src/hooks/useGoals.ts`, `src/components/views/GoalsView.tsx` | DONE | GS-013 | -| Growth home: repositories with profiles or goals, quick stats | `/growth` | PLANNED | GS-014 | +| Growth home: repositories with profiles or goals, quick stats | `src/components/growth/GrowthHome.tsx`, `src/utils/growthHome.ts`, `/growth` | DONE | GS-014 | | Workspace overview per repository | `/growth/r/:owner/:repo` | PLANNED | GS-015 | | Shell i18n, responsive layout, theme parity | shell components | PLANNED | GS-016 | | Growth store schema (profiles, interventions, plans, items, assets, performance) | `server/growth/store.ts` | PLANNED | GS-020 | diff --git a/growth-studio-project/tasks/PROGRESS.md b/growth-studio-project/tasks/PROGRESS.md index c3976cf..ab6bf66 100644 --- a/growth-studio-project/tasks/PROGRESS.md +++ b/growth-studio-project/tasks/PROGRESS.md @@ -17,7 +17,7 @@ phase-closing tasks) are appended with `PENDING` rows and matching task files. | GS-011 | COMPLETED | Added the authenticated Growth Studio shell with dedicated top bar, responsive sidebar, repository switching, route placeholders, and shared theme handling | VALIDATION OK; typecheck; 30 test files and 159 tests; production build; Growth route utility tests; git diff --check | 2026-09-04 | | GS-012 | COMPLETED | Replaced the dashboard goals tab with a new-window Growth Studio entry and added the legacy goals redirect | VALIDATION OK; typecheck; 30 test files and 159 tests; production build; acceptance grep; git diff --check | 2026-09-04 | | GS-013 | COMPLETED | Added the repository-scoped Missions panel with locked goal creation, refreshable account-aware loading, and new-window AI preferences | VALIDATION OK; typecheck; 31 test files and 161 tests; production build; useGoals cancellation and refresh tests; git diff --check | 2026-09-04 | -| GS-014 | PENDING | — | — | — | +| GS-014 | COMPLETED | Added the Growth home with goal-backed repository cards, quick stats, fallback identities, and a remaining-repository starter picker | VALIDATION OK; typecheck; 32 test files and 164 tests; production build; Growth home summary and account-wide goal hook tests; git diff --check | 2026-09-04 | | GS-015 | PENDING | — | — | — | | GS-016 | PENDING | — | — | — | | GS-020 | PENDING | — | — | — | diff --git a/src/components/growth/GrowthHome.tsx b/src/components/growth/GrowthHome.tsx new file mode 100644 index 0000000..6033864 --- /dev/null +++ b/src/components/growth/GrowthHome.tsx @@ -0,0 +1,140 @@ +import { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { useI18n } from "../../i18n/I18nProvider"; +import { useGoals } from "../../hooks/useGoals"; +import type { GhRepo } from "../../types/github"; +import { buildGrowthHomeSummary } from "../../utils/growthHome"; +import { growthRepositoryPath } from "../../utils/growthRoutes"; +import { Avatar } from "../common/Avatar"; +import { RepositoryPicker } from "../common/RepositoryPicker"; +import { GoalsLoadingState } from "../views/GoalsLoadingState"; + +interface GrowthHomeProps { + accountId: string | null; + enabled: boolean; + repos: GhRepo[]; + repositoriesLoading: boolean; + onSelectRepository: (repository: string) => void; +} + +export function GrowthHome({ + accountId, + enabled, + repos, + repositoriesLoading, + onSelectRepository, +}: GrowthHomeProps) { + const { t } = useI18n(); + const { goals, loading: goalsLoading, error } = useGoals({ accountId, enabled }); + const summary = useMemo(() => buildGrowthHomeSummary(goals, repos), [goals, repos]); + const loading = repositoriesLoading || goalsLoading; + + return ( +
+
+
+ {t("growth.homeEyebrow")} +

{t("growth.homeTitle")}

+

{t("growth.homeDescription")}

+
+ {!loading ? ( +
+
{t("growth.homeWorkspaces")}
{summary.workspaces.length}
+
{t("growth.homeMissions")}
{summary.totalGoals}
+
{t("growth.homeCompleted")}
{summary.completedGoals}
+
+ ) : null} +
+ + {loading ? ( +
+ +
+ ) : ( + <> + {error ?
{t("growth.goalsLoadError", { message: error })}
: null} + + {summary.workspaces.length ? ( +
+
+
+

{t("growth.activeRepositories")}

+

{t("growth.activeRepositoriesDescription")}

+
+ {summary.workspaces.length} +
+
+ {summary.workspaces.map((workspace) => { + const owner = workspace.repo?.owner.login ?? workspace.repository.split("/")[0]; + const workspacePath = growthRepositoryPath(workspace.repository) ?? "/growth"; + const missionsPath = growthRepositoryPath(workspace.repository, "missions") ?? "/growth"; + return ( +
+
+ +
+

{workspace.repository}

+

{workspace.repo?.description || t("growth.noRepositoryDescription")}

+
+
+
+ {t("growth.missionsCount", { count: workspace.goals.length })} + {workspace.completedGoals}/{workspace.goals.length} + {t("growth.completedMissionsCount")} +
+
+ {t("growth.openWorkspace")} + {t("growth.openMissions")} +
+
+ ); + })} +
+
+ ) : !error ? ( +
+ +
+

{t("growth.homeEmptyTitle")}

+

{t("growth.homeEmptyDescription")}

+
+ {t("growth.chooseRepository")} +
+ ) : null} + +
+
+ {t("growth.startRepositoryEyebrow")} +

{t("growth.startRepositoryTitle")}

+

{t("growth.startRepositoryDescription")}

+
+ {summary.starterRepositories.length ? ( + { + if (repository) onSelectRepository(repository); + }} + /> + ) : ( +

+ {repos.length ? t("growth.allRepositoriesActive") : t("growth.noRepositoriesAvailable")} +

+ )} +
+ + )} +
+ ); +} + +function TargetIcon() { + return ( + + + + + + ); +} diff --git a/src/components/growth/GrowthStudioApp.tsx b/src/components/growth/GrowthStudioApp.tsx index ffd532b..9e91dcd 100644 --- a/src/components/growth/GrowthStudioApp.tsx +++ b/src/components/growth/GrowthStudioApp.tsx @@ -18,6 +18,7 @@ import { } from "../../utils/growthRoutes"; import { AuthGate } from "../AuthGate"; import { GoalsView } from "../views/GoalsView"; +import { GrowthHome } from "./GrowthHome"; import { GrowthSidebar } from "./GrowthSidebar"; import { GrowthTopBar, type GrowthTheme } from "./GrowthTopBar"; @@ -214,7 +215,18 @@ export function GrowthStudioApp() {
) : null} - } /> + + )} + /> } /> } /> } /> diff --git a/src/hooks/useGoals.ts b/src/hooks/useGoals.ts index 9c248ff..4b09b6e 100644 --- a/src/hooks/useGoals.ts +++ b/src/hooks/useGoals.ts @@ -5,7 +5,8 @@ import type { RepositoryGoal } from "../types/goals"; interface UseGoalsOptions { accountId: string | null; enabled: boolean; - repository: string; + /** Omit the repository to load goals across the active account. */ + repository?: string; } export interface GoalsState { @@ -15,17 +16,18 @@ export interface GoalsState { refresh: () => Promise; } -/** Loads the goals for one repository and cancels stale account or route requests. */ +/** Loads repository-scoped or account-wide goals and cancels stale requests. */ export function useGoals({ accountId, enabled, repository }: UseGoalsOptions): GoalsState { + const shouldLoad = enabled && repository !== ""; const [goals, setGoals] = useState([]); - const [loading, setLoading] = useState(enabled); + const [loading, setLoading] = useState(shouldLoad); const [error, setError] = useState(""); const [loadedKey, setLoadedKey] = useState(""); const requestRef = useRef(null); - const requestKey = JSON.stringify([accountId, repository]); + const requestKey = JSON.stringify([accountId, repository ?? "*"]); const refresh = useCallback(async () => { - if (!enabled || !repository) return; + if (!shouldLoad) return; requestRef.current?.abort(); const controller = new AbortController(); @@ -36,7 +38,9 @@ export function useGoals({ accountId, enabled, repository }: UseGoalsOptions): G try { const result = await fetchGoals(controller.signal); if (!controller.signal.aborted && requestRef.current === controller) { - setGoals(result.goals.filter((goal) => goal.repository === repository)); + setGoals(repository === undefined + ? result.goals + : result.goals.filter((goal) => goal.repository === repository)); } } catch (cause) { if (!controller.signal.aborted && (cause as Error).name !== "AbortError" && requestRef.current === controller) { @@ -49,14 +53,14 @@ export function useGoals({ accountId, enabled, repository }: UseGoalsOptions): G setLoading(false); } } - }, [enabled, repository, requestKey]); + }, [repository, requestKey, shouldLoad]); useEffect(() => { requestRef.current?.abort(); requestRef.current = null; setGoals([]); setError(""); - if (!enabled || !repository) { + if (!shouldLoad) { setLoading(false); return; } @@ -66,11 +70,11 @@ export function useGoals({ accountId, enabled, repository }: UseGoalsOptions): G requestRef.current?.abort(); requestRef.current = null; }; - }, [enabled, repository, refresh]); + }, [refresh, shouldLoad]); return { goals, - loading: loading || Boolean(enabled && repository && loadedKey !== requestKey), + loading: loading || Boolean(shouldLoad && loadedKey !== requestKey), error, refresh, }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 84f9fb9..14ddba2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -165,6 +165,28 @@ export const en = { "growth.repositoryLoadError": "Could not load repositories: {message}", "growth.goalsLoadError": "Could not load missions: {message}", "growth.home": "Home", + "growth.homeEyebrow": "Your growth portfolio", + "growth.homeTitle": "Build momentum, repository by repository", + "growth.homeDescription": "Keep every active growth mission in view and open a focused workspace for the repository that needs attention next.", + "growth.homeWorkspaces": "Workspaces", + "growth.homeMissions": "Missions", + "growth.homeCompleted": "Completed", + "growth.homeLoading": "Loading Growth Studio home…", + "growth.activeRepositories": "Active repository workspaces", + "growth.activeRepositoriesDescription": "Repositories with one or more growth missions.", + "growth.homeEmptyTitle": "No growth missions yet", + "growth.homeEmptyDescription": "Choose a repository below to open its workspace and create the first mission.", + "growth.chooseRepository": "Choose a repository", + "growth.startRepositoryEyebrow": "Start something new", + "growth.startRepositoryTitle": "Start with a repository", + "growth.startRepositoryDescription": "Open any other repository in its Growth Studio workspace.", + "growth.startRepositoryPlaceholder": "Search repositories by name, language, or description…", + "growth.allRepositoriesActive": "Every available repository already has an active growth workspace.", + "growth.noRepositoriesAvailable": "No repositories are available for this account.", + "growth.missionsCount": "{count} missions", + "growth.completedMissionsCount": "completed missions", + "growth.openWorkspace": "Open workspace", + "growth.openMissions": "View missions", "growth.unifiedCalendar": "Unified calendar", "growth.review": "Review", "growth.settings": "Settings", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index ee48275..95a5429 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -167,6 +167,28 @@ export const it: Record = { "growth.repositoryLoadError": "Impossibile caricare le repository: {message}", "growth.goalsLoadError": "Impossibile caricare le missioni: {message}", "growth.home": "Home", + "growth.homeEyebrow": "Il tuo portfolio growth", + "growth.homeTitle": "Crea slancio, una repository alla volta", + "growth.homeDescription": "Tieni sotto controllo ogni missione growth attiva e apri uno spazio dedicato per la repository che richiede attenzione.", + "growth.homeWorkspaces": "Spazi di lavoro", + "growth.homeMissions": "Missioni", + "growth.homeCompleted": "Completate", + "growth.homeLoading": "Caricamento home di Growth Studio…", + "growth.activeRepositories": "Spazi repository attivi", + "growth.activeRepositoriesDescription": "Repository con una o più missioni growth.", + "growth.homeEmptyTitle": "Nessuna missione growth", + "growth.homeEmptyDescription": "Scegli una repository qui sotto per aprire il suo spazio e creare la prima missione.", + "growth.chooseRepository": "Scegli una repository", + "growth.startRepositoryEyebrow": "Inizia qualcosa di nuovo", + "growth.startRepositoryTitle": "Inizia da una repository", + "growth.startRepositoryDescription": "Apri qualsiasi altra repository nel suo spazio Growth Studio.", + "growth.startRepositoryPlaceholder": "Cerca per nome, linguaggio o descrizione…", + "growth.allRepositoriesActive": "Ogni repository disponibile ha già uno spazio growth attivo.", + "growth.noRepositoriesAvailable": "Nessuna repository disponibile per questo account.", + "growth.missionsCount": "{count} missioni", + "growth.completedMissionsCount": "missioni completate", + "growth.openWorkspace": "Apri spazio", + "growth.openMissions": "Vedi missioni", "growth.unifiedCalendar": "Calendario unificato", "growth.review": "Revisione", "growth.settings": "Impostazioni", diff --git a/src/styles.css b/src/styles.css index 76077b5..e70afea 100644 --- a/src/styles.css +++ b/src/styles.css @@ -11,3 +11,4 @@ @import "./styles/preferences.css"; @import "./styles/goals.css"; @import "./styles/growth/shell.css"; +@import "./styles/growth/home.css"; diff --git a/src/styles/growth/home.css b/src/styles/growth/home.css new file mode 100644 index 0000000..ef727dd --- /dev/null +++ b/src/styles/growth/home.css @@ -0,0 +1,337 @@ +.mode-growth .growth-home { + display: grid; + gap: 20px; +} + +.mode-growth .growth-home .btn { + width: auto; + padding: 6px 9px; +} + +.mode-growth .growth-home-hero { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(310px, .8fr); + align-items: end; + gap: clamp(24px, 4vw, 56px); + padding: clamp(26px, 4vw, 48px); + background: + radial-gradient(480px 220px at 100% 0, var(--accent-soft), transparent 68%), + linear-gradient(145deg, color-mix(in srgb, var(--panel) 95%, var(--accent) 5%), var(--panel)); + border: 1px solid var(--border-soft); + border-radius: 16px; + box-shadow: var(--shadow); +} + +.mode-growth .growth-home-intro > span, +.mode-growth .growth-home-start > div > span { + color: var(--accent); + font-size: 10px; + font-weight: 850; + letter-spacing: .12em; + text-transform: uppercase; +} + +.mode-growth .growth-home-intro h1 { + max-width: 720px; + margin: 9px 0 0; + font-size: clamp(29px, 4vw, 48px); + line-height: 1.05; + letter-spacing: -.035em; +} + +.mode-growth .growth-home-intro p { + max-width: 680px; + margin: 14px 0 0; + color: var(--muted); + font-size: 13.5px; + line-height: 1.6; +} + +.mode-growth .growth-home-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.mode-growth .growth-home-stats > div { + display: grid; + align-content: space-between; + min-height: 86px; + padding: 13px; + background: color-mix(in srgb, var(--panel-2) 78%, transparent); + border: 1px solid var(--border-soft); + border-radius: 11px; +} + +.mode-growth .growth-home-stats dt { + color: var(--muted); + font-size: 9px; + font-weight: 750; + letter-spacing: .06em; + line-height: 1.35; + text-transform: uppercase; +} + +.mode-growth .growth-home-stats dd { + margin: 9px 0 0; + color: var(--text); + font-size: 25px; + font-weight: 800; + line-height: 1; +} + +.mode-growth .growth-home-section { + display: grid; + gap: 13px; +} + +.mode-growth .growth-home-section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 18px; + padding: 2px 3px; +} + +.mode-growth .growth-home-section-heading h2, +.mode-growth .growth-home-start h2, +.mode-growth .growth-home-empty h2 { + margin: 0; + font-size: 17px; +} + +.mode-growth .growth-home-section-heading p, +.mode-growth .growth-home-start p, +.mode-growth .growth-home-empty p { + margin: 5px 0 0; + color: var(--muted); + font-size: 11.5px; + line-height: 1.5; +} + +.mode-growth .growth-home-section-heading > span { + display: grid; + place-items: center; + min-width: 29px; + height: 25px; + padding: 0 8px; + color: var(--accent); + background: var(--accent-faint); + border: 1px solid var(--accent-border); + border-radius: 999px; + font-size: 11px; + font-weight: 800; +} + +.mode-growth .growth-home-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr)); + gap: 13px; +} + +.mode-growth .growth-home-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 18px; + padding: 18px; + background: linear-gradient(145deg, color-mix(in srgb, var(--panel) 96%, var(--accent-2) 4%), var(--panel)); + border: 1px solid color-mix(in srgb, var(--accent-2) 24%, var(--border-soft)); + border-radius: 14px; + box-shadow: 0 12px 34px rgba(0, 0, 0, .14); +} + +.mode-growth .growth-home-card-identity { + display: flex; + align-items: center; + min-width: 0; + gap: 12px; +} + +.mode-growth .growth-home-card-identity .avatar { + flex: 0 0 auto; + border: 1px solid var(--accent-border); + box-shadow: 0 0 0 3px var(--accent-faint); +} + +.mode-growth .growth-home-card-identity > div { + min-width: 0; +} + +.mode-growth .growth-home-card-identity h3 { + overflow: hidden; + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mode-growth .growth-home-card-identity p { + display: -webkit-box; + overflow: hidden; + margin: 5px 0 0; + color: var(--muted); + font-size: 11px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.mode-growth .growth-home-card-progress { + display: grid; + min-width: 78px; + justify-items: end; + align-content: center; +} + +.mode-growth .growth-home-card-progress > span { + color: var(--muted); + font-size: 9px; +} + +.mode-growth .growth-home-card-progress strong { + margin: 4px 0 2px; + font-size: 24px; + line-height: 1; +} + +.mode-growth .growth-home-card-progress small { + color: var(--muted-2); + font-size: 13px; +} + +.mode-growth .growth-home-card-actions { + display: flex; + grid-column: 1 / -1; + gap: 8px; + padding-top: 13px; + border-top: 1px solid var(--border-soft); +} + +.mode-growth .growth-home-card-actions .btn { + justify-content: center; + text-decoration: none; +} + +.mode-growth .growth-home-empty, +.mode-growth .growth-home-start { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: 14px; +} + +.mode-growth .growth-home-empty { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 18px; + border-style: dashed; +} + +.mode-growth .growth-home-empty > span { + display: grid; + place-items: center; + width: 42px; + height: 42px; + color: var(--accent); + background: var(--accent-faint); + border: 1px solid var(--accent-border); + border-radius: 11px; +} + +.mode-growth .growth-home-empty .btn { + text-decoration: none; + white-space: nowrap; +} + +.mode-growth .growth-home-start { + display: grid; + grid-template-columns: minmax(220px, .8fr) minmax(300px, 1.2fr); + align-items: center; + gap: clamp(22px, 4vw, 54px); + padding: clamp(20px, 3vw, 30px); + scroll-margin-top: 96px; +} + +.mode-growth .growth-home-start .repository-picker-input { + min-height: 42px; + background: var(--panel-2); +} + +.mode-growth .growth-home-start .repository-picker-menu { + width: 100%; +} + +.mode-growth .growth-home-all-active, +.mode-growth .growth-home-error { + padding: 11px 13px; + border-radius: 9px; +} + +.mode-growth .growth-home-all-active { + margin: 0; + color: var(--muted); + background: var(--panel-2); + border: 1px solid var(--border-soft); +} + +.mode-growth .growth-home-error { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--border)); + font-size: 12px; +} + +.mode-growth .growth-home-loading .goal-skeleton-studio { + display: none; +} + +@media (max-width: 840px) { + .mode-growth .growth-home-hero, + .mode-growth .growth-home-start { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .mode-growth .growth-home-hero { + padding: 24px 20px; + } + + .mode-growth .growth-home-stats { + grid-template-columns: 1fr; + } + + .mode-growth .growth-home-stats > div { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + min-height: 0; + } + + .mode-growth .growth-home-stats dd { + margin: 0; + } + + .mode-growth .growth-home-card, + .mode-growth .growth-home-empty { + grid-template-columns: 1fr; + } + + .mode-growth .growth-home-card-progress { + grid-template-columns: 1fr auto auto; + align-items: baseline; + justify-items: start; + gap: 5px; + } + + .mode-growth .growth-home-card-actions { + grid-column: 1; + flex-direction: column; + } + + .mode-growth .growth-home-empty .btn { + justify-self: start; + } +} diff --git a/src/utils/growthHome.ts b/src/utils/growthHome.ts new file mode 100644 index 0000000..f531704 --- /dev/null +++ b/src/utils/growthHome.ts @@ -0,0 +1,39 @@ +import type { GhRepo } from "../types/github"; +import type { RepositoryGoal } from "../types/goals"; +import { calculateGoalProgress, groupGoalsByRepository } from "./goals"; + +export interface GrowthHomeWorkspace { + repository: string; + repo: GhRepo | null; + goals: RepositoryGoal[]; + completedGoals: number; +} + +export interface GrowthHomeSummary { + workspaces: GrowthHomeWorkspace[]; + starterRepositories: GhRepo[]; + totalGoals: number; + completedGoals: number; +} + +/** Builds account-wide home cards while preserving goals whose repository metadata is unavailable. */ +export function buildGrowthHomeSummary( + goals: RepositoryGoal[], + repositories: GhRepo[], +): GrowthHomeSummary { + const repositoriesByName = new Map(repositories.map((repo) => [repo.nameWithOwner, repo])); + const workspaces = groupGoalsByRepository(goals).map((group) => ({ + repository: group.repository, + repo: repositoriesByName.get(group.repository) ?? null, + goals: group.goals, + completedGoals: group.goals.filter((goal) => calculateGoalProgress(goal).completed).length, + })); + const representedRepositories = new Set(workspaces.map((workspace) => workspace.repository)); + + return { + workspaces, + starterRepositories: repositories.filter((repo) => !representedRepositories.has(repo.nameWithOwner)), + totalGoals: goals.length, + completedGoals: workspaces.reduce((total, workspace) => total + workspace.completedGoals, 0), + }; +} diff --git a/tests/hooks/useGoals.test.ts b/tests/hooks/useGoals.test.ts index 12e1a12..9919e5a 100644 --- a/tests/hooks/useGoals.test.ts +++ b/tests/hooks/useGoals.test.ts @@ -14,7 +14,7 @@ vi.mock("../../src/api/github", () => ({ interface HarnessProps { accountId: string; - repository: string; + repository?: string; } let latest: GoalsState; @@ -61,6 +61,18 @@ afterEach(async () => { }); describe("useGoals", () => { + it("loads every goal when no repository scope is provided", async () => { + api.fetchGoals.mockResolvedValueOnce({ + ok: true, + goals: [goal("one", "owner/one"), goal("two", "owner/two")], + }); + + await render({ accountId: "account-a" }); + + expect(latest.goals.map((entry) => entry.id)).toEqual(["one", "two"]); + expect(latest.loading).toBe(false); + }); + it("loads only goals for the selected repository and supports refresh", async () => { api.fetchGoals .mockResolvedValueOnce({ ok: true, goals: [goal("one", "owner/one"), goal("two", "owner/two")] }) diff --git a/tests/utils/growthHome.test.ts b/tests/utils/growthHome.test.ts new file mode 100644 index 0000000..7a83023 --- /dev/null +++ b/tests/utils/growthHome.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { GhRepo } from "../../src/types/github"; +import type { RepositoryGoal } from "../../src/types/goals"; +import { buildGrowthHomeSummary } from "../../src/utils/growthHome"; + +function repo(nameWithOwner: string): GhRepo { + const [owner, name] = nameWithOwner.split("/"); + return { + nameWithOwner, + name, + owner: { login: owner }, + description: `${name} description`, + stargazerCount: 10, + forkCount: 2, + primaryLanguage: null, + updatedAt: "2026-09-04T00:00:00.000Z", + pushedAt: "2026-09-04T00:00:00.000Z", + visibility: "PUBLIC", + isPrivate: false, + isArchived: false, + isFork: false, + url: `https://github.com/${nameWithOwner}`, + }; +} + +function goal(id: string, repository: string, currentValue: number, targetValue = 10): RepositoryGoal { + return { + id, + accountId: "account-a", + repository, + metric: "stars", + targetValue, + currentValue, + deadline: "2026-12-31", + createdAt: "2026-09-04T00:00:00.000Z", + updatedAt: "2026-09-04T00:00:00.000Z", + suggestions: [], + suggestionsGeneratedAt: null, + aiEnabled: true, + }; +} + +describe("buildGrowthHomeSummary", () => { + it("groups mission counts and excludes represented repositories from the starter list", () => { + const active = repo("acme/active"); + const starter = repo("acme/starter"); + const summary = buildGrowthHomeSummary([ + goal("complete", active.nameWithOwner, 10), + goal("open", active.nameWithOwner, 4), + ], [active, starter]); + + expect(summary).toMatchObject({ totalGoals: 2, completedGoals: 1 }); + expect(summary.workspaces).toHaveLength(1); + expect(summary.workspaces[0]).toMatchObject({ + repository: "acme/active", + repo: active, + completedGoals: 1, + }); + expect(summary.starterRepositories.map((entry) => entry.nameWithOwner)).toEqual(["acme/starter"]); + }); + + it("keeps a fallback workspace when repository metadata is unavailable", () => { + const summary = buildGrowthHomeSummary( + [goal("legacy", "legacy/missing", 1)], + [repo("acme/starter")], + ); + + expect(summary.workspaces[0]).toMatchObject({ + repository: "legacy/missing", + repo: null, + completedGoals: 0, + }); + expect(summary.starterRepositories).toHaveLength(1); + }); +}); From ace3d1658f0c62c43c7849986cc10b612ea649ee Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 09:51:51 +0200 Subject: [PATCH 12/54] feat(growth): add repository workspace overview --- .../docs/GS_FEATURE_MATRIX.md | 2 +- growth-studio-project/tasks/PROGRESS.md | 2 +- src/api/growth.ts | 31 ++ src/components/growth/GrowthStudioApp.tsx | 14 +- .../growth/GrowthWorkspaceOverview.tsx | 252 ++++++++++++ src/i18n/en.ts | 32 ++ src/i18n/it.ts | 32 ++ src/styles.css | 1 + src/styles/growth/overview.css | 366 ++++++++++++++++++ src/types/growth.ts | 173 +++++++++ src/utils/growthWorkspace.ts | 30 ++ .../growth/GrowthWorkspaceOverview.test.ts | 110 ++++++ tests/utils/growthWorkspace.test.ts | 42 ++ 13 files changed, 1084 insertions(+), 3 deletions(-) create mode 100644 src/api/growth.ts create mode 100644 src/components/growth/GrowthWorkspaceOverview.tsx create mode 100644 src/styles/growth/overview.css create mode 100644 src/types/growth.ts create mode 100644 src/utils/growthWorkspace.ts create mode 100644 tests/components/growth/GrowthWorkspaceOverview.test.ts create mode 100644 tests/utils/growthWorkspace.test.ts diff --git a/growth-studio-project/docs/GS_FEATURE_MATRIX.md b/growth-studio-project/docs/GS_FEATURE_MATRIX.md index d9f43d1..33aa06c 100644 --- a/growth-studio-project/docs/GS_FEATURE_MATRIX.md +++ b/growth-studio-project/docs/GS_FEATURE_MATRIX.md @@ -19,7 +19,7 @@ Authoritative inventory of Growth Studio capabilities. Statuses: `EXISTING` | Main-menu entry opening `/growth` in a new window; `goals` tab removed; `/goals` redirect | `src/App.tsx`, `src/main.tsx`, `src/utils/dataRequirements.ts`, `src/components/SidebarControls.tsx` | DONE | GS-012 | | Missions panel hosting the existing goals UI | `src/components/growth/GrowthStudioApp.tsx`, `src/hooks/useGoals.ts`, `src/components/views/GoalsView.tsx` | DONE | GS-013 | | Growth home: repositories with profiles or goals, quick stats | `src/components/growth/GrowthHome.tsx`, `src/utils/growthHome.ts`, `/growth` | DONE | GS-014 | -| Workspace overview per repository | `/growth/r/:owner/:repo` | PLANNED | GS-015 | +| Workspace overview per repository | `src/components/growth/GrowthWorkspaceOverview.tsx`, `src/types/growth.ts`, `src/api/growth.ts`, `/growth/r/:owner/:repo` | DONE | GS-015 | | Shell i18n, responsive layout, theme parity | shell components | PLANNED | GS-016 | | Growth store schema (profiles, interventions, plans, items, assets, performance) | `server/growth/store.ts` | PLANNED | GS-020 | | One-shot migration of legacy suggestions and proposals | `server/growth/store.ts` | PLANNED | GS-021 | diff --git a/growth-studio-project/tasks/PROGRESS.md b/growth-studio-project/tasks/PROGRESS.md index ab6bf66..fd4fd1f 100644 --- a/growth-studio-project/tasks/PROGRESS.md +++ b/growth-studio-project/tasks/PROGRESS.md @@ -18,7 +18,7 @@ phase-closing tasks) are appended with `PENDING` rows and matching task files. | GS-012 | COMPLETED | Replaced the dashboard goals tab with a new-window Growth Studio entry and added the legacy goals redirect | VALIDATION OK; typecheck; 30 test files and 159 tests; production build; acceptance grep; git diff --check | 2026-09-04 | | GS-013 | COMPLETED | Added the repository-scoped Missions panel with locked goal creation, refreshable account-aware loading, and new-window AI preferences | VALIDATION OK; typecheck; 31 test files and 161 tests; production build; useGoals cancellation and refresh tests; git diff --check | 2026-09-04 | | GS-014 | COMPLETED | Added the Growth home with goal-backed repository cards, quick stats, fallback identities, and a remaining-repository starter picker | VALIDATION OK; typecheck; 32 test files and 164 tests; production build; Growth home summary and account-wide goal hook tests; git diff --check | 2026-09-04 | -| GS-015 | PENDING | — | — | — | +| GS-015 | COMPLETED | Added the repository workspace overview with mission progress, typed phase 2 activity summaries, upcoming content, and panel shortcuts | VALIDATION OK; typecheck; 34 test files and 168 tests; production build; overview rendering and goal summary tests; git diff --check | 2026-09-04 | | GS-016 | PENDING | — | — | — | | GS-020 | PENDING | — | — | — | | GS-021 | PENDING | — | — | — | diff --git a/src/api/growth.ts b/src/api/growth.ts new file mode 100644 index 0000000..f5df5ab --- /dev/null +++ b/src/api/growth.ts @@ -0,0 +1,31 @@ +import type { GrowthWorkspaceSummary } from "../types/growth"; + +/** + * Phase 1 workspace summary. GS-022 replaces this placeholder with the + * account-scoped Growth API request without changing overview callers. + */ +export async function fetchGrowthWorkspaceSummary( + repository: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw new DOMException("The request was aborted", "AbortError"); + + return { + repository, + interventionsByStatus: { + proposed: 0, + accepted: 0, + dismissed: 0, + done: 0, + }, + contentItemsByStatus: { + idea: 0, + draft: 0, + ready: 0, + scheduled: 0, + published: 0, + skipped: 0, + }, + nextSevenDays: [], + }; +} diff --git a/src/components/growth/GrowthStudioApp.tsx b/src/components/growth/GrowthStudioApp.tsx index 9e91dcd..20f7893 100644 --- a/src/components/growth/GrowthStudioApp.tsx +++ b/src/components/growth/GrowthStudioApp.tsx @@ -21,6 +21,7 @@ import { GoalsView } from "../views/GoalsView"; import { GrowthHome } from "./GrowthHome"; import { GrowthSidebar } from "./GrowthSidebar"; import { GrowthTopBar, type GrowthTheme } from "./GrowthTopBar"; +import { GrowthWorkspaceOverview } from "./GrowthWorkspaceOverview"; type AuthState = "checking" | "anonymous" | "authenticated"; type GrowthPanelKey = @@ -230,7 +231,18 @@ export function GrowthStudioApp() { } /> } /> } /> - } /> + + ) : } + /> ( + GOAL_METRIC_DEFINITIONS.map((metric) => [metric.id, metric.label]), +); + +const interventionStatusLabels: Record = { + proposed: "growth.status.proposed", + accepted: "growth.status.accepted", + dismissed: "growth.status.dismissed", + done: "growth.status.done", +}; + +const contentStatusLabels: Record = { + idea: "growth.status.idea", + draft: "growth.status.draft", + ready: "growth.status.ready", + scheduled: "growth.status.scheduled", + published: "growth.status.published", + skipped: "growth.status.skipped", +}; + +export function GrowthWorkspaceOverview({ + accountId, + enabled, + repository, + repos, +}: GrowthWorkspaceOverviewProps) { + const { language, t } = useI18n(); + const { goals, loading: goalsLoading, error: goalsError } = useGoals({ accountId, enabled, repository }); + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(enabled); + const [summaryError, setSummaryError] = useState(""); + const repo = useMemo( + () => repos.find((candidate) => candidate.nameWithOwner === repository) ?? null, + [repos, repository], + ); + const goalSummary = useMemo(() => buildGrowthWorkspaceGoalSummary(goals), [goals]); + const workspaceBase = growthRepositoryPath(repository) ?? "/growth"; + const owner = repo?.owner.login ?? repository.split("/")[0]; + + useEffect(() => { + setSummary(null); + setSummaryError(""); + if (!enabled || !repository) { + setSummaryLoading(false); + return; + } + + const controller = new AbortController(); + setSummaryLoading(true); + void fetchGrowthWorkspaceSummary(repository, controller.signal) + .then((result) => { + if (!controller.signal.aborted) setSummary(result); + }) + .catch((error: unknown) => { + if (!controller.signal.aborted && (error as Error).name !== "AbortError") { + setSummaryError((error as Error).message); + } + }) + .finally(() => { + if (!controller.signal.aborted) setSummaryLoading(false); + }); + + return () => controller.abort(); + }, [accountId, enabled, repository]); + + return ( +
+
+
+ +
+ {t("growth.overviewEyebrow")} +

{repository}

+

{repo?.description || t("growth.noRepositoryDescription")}

+
+
+ {repo ? ( +
+
{t("growth.overviewStars")}
{formatNumber(repo.stargazerCount)}
+
{t("growth.overviewForks")}
{formatNumber(repo.forkCount)}
+
{t("growth.overviewLanguage")}
{repo.primaryLanguage?.name ?? t("common.unavailable")}
+
+ ) : null} +
+ + {goalsError ? ( +
+ {t("growth.goalsLoadError", { message: goalsError })} +
+ ) : null} + {summaryError ? ( +
+ {t("growth.summaryLoadError", { message: summaryError })} +
+ ) : null} + +
+
+
+
+ {t("growth.overviewProgressEyebrow")} +

{t("growth.overviewMissionsTitle")}

+
+ {!goalsLoading ? ( + {t("growth.overviewMissionsSummary", { + completed: goalSummary.completed, + count: goalSummary.goals.length, + })} + ) : null} +
+ + {goalsLoading ? ( +

{t("growth.overviewLoadingMissions")}

+ ) : goalSummary.goals.length ? ( +
+ {goalSummary.goals.map(({ goal, progress }) => ( +
+
+ {metricLabels.get(goal.metric) ?? goal.metric} + {progress.percentage}% +
+
+ +
+
+ {formatNumber(goal.currentValue)} / {formatNumber(goal.targetValue)} + {progress.completed + ? t("goals.completed") + : progress.overdue + ? t("goals.overdue") + : t("goals.daysLeft", { count: progress.daysRemaining })} +
+
+ ))} +
+ ) : ( +
+

{t("growth.overviewNoMissions")}

+ {t("growth.overviewCreateMission")} +
+ )} +
+ +
+
+
{t("growth.overviewBacklogEyebrow")}

{t("growth.overviewInterventionsTitle")}

+
+ +
+ +
+
+
{t("growth.overviewEditorialEyebrow")}

{t("growth.overviewContentTitle")}

+
+ +
+ +
+
+
{t("growth.overviewScheduleEyebrow")}

{t("growth.overviewNextSevenDays")}

+
+ {summaryLoading ? ( +

{t("growth.overviewLoadingSummary")}

+ ) : summary?.nextSevenDays.length ? ( +
+ {summary.nextSevenDays.map((item) => ( +
+
{item.title}{item.channel}
+ {item.scheduledFor ? ( + + ) : null} +
+ ))} +
+ ) : ( +

{t("growth.overviewNextEmpty")}

+ )} +
+
+ +
+
{t("growth.overviewActionsEyebrow")}

{t("growth.overviewActionsTitle")}

{t("growth.overviewActionsDescription")}

+ +
+
+ ); +} + +interface StatusCountersProps { + statuses: readonly Status[]; + labels: Record; + values: Record | undefined; + loading: boolean; +} + +function StatusCounters({ statuses, labels, values, loading }: StatusCountersProps) { + const { t } = useI18n(); + return ( +
+ {statuses.map((status) => ( +
+
{t(labels[status])}
+
{loading ? "…" : values?.[status] ?? 0}
+
+ ))} +
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 14ddba2..95910c5 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -199,6 +199,38 @@ export const en = { "growth.noRepositorySelected": "Select a repository to open its workspace.", "growth.repositoryWorkspace": "Repository workspace", "growth.placeholderDescription": "This area is ready for its dedicated Growth Studio panel.", + "growth.overviewEyebrow": "Repository workspace", + "growth.overviewStars": "Stars", + "growth.overviewForks": "Forks", + "growth.overviewLanguage": "Language", + "growth.summaryLoadError": "Could not load the workspace summary: {message}", + "growth.overviewProgressEyebrow": "Mission progress", + "growth.overviewMissionsTitle": "Growth missions", + "growth.overviewMissionsSummary": "{completed} of {count} completed", + "growth.overviewLoadingMissions": "Loading mission progress…", + "growth.overviewNoMissions": "No missions exist for this repository yet.", + "growth.overviewCreateMission": "Create a mission", + "growth.overviewBacklogEyebrow": "Action backlog", + "growth.overviewInterventionsTitle": "Interventions by status", + "growth.overviewEditorialEyebrow": "Editorial plan", + "growth.overviewContentTitle": "Content items by status", + "growth.overviewScheduleEyebrow": "Upcoming schedule", + "growth.overviewNextSevenDays": "Next 7 days", + "growth.overviewLoadingSummary": "Loading workspace activity…", + "growth.overviewNextEmpty": "No content is scheduled for the next 7 days.", + "growth.overviewActionsEyebrow": "Keep moving", + "growth.overviewActionsTitle": "Workspace quick actions", + "growth.overviewActionsDescription": "Jump directly to the part of this repository's growth plan that needs attention.", + "growth.status.proposed": "Proposed", + "growth.status.accepted": "Accepted", + "growth.status.dismissed": "Dismissed", + "growth.status.done": "Done", + "growth.status.idea": "Ideas", + "growth.status.draft": "Drafts", + "growth.status.ready": "Ready", + "growth.status.scheduled": "Scheduled", + "growth.status.published": "Published", + "growth.status.skipped": "Skipped", "goals.createTitle": "Set a repository goal", "goals.createDescription": "Track a measurable result and get an action plan based on repository activity.", "goals.repository": "Repository", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 95a5429..40ff02b 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -201,6 +201,38 @@ export const it: Record = { "growth.noRepositorySelected": "Seleziona una repository per aprire il suo spazio di lavoro.", "growth.repositoryWorkspace": "Spazio della repository", "growth.placeholderDescription": "Quest’area è pronta per il suo pannello Growth Studio dedicato.", + "growth.overviewEyebrow": "Spazio della repository", + "growth.overviewStars": "Stelle", + "growth.overviewForks": "Fork", + "growth.overviewLanguage": "Linguaggio", + "growth.summaryLoadError": "Impossibile caricare il riepilogo dello spazio: {message}", + "growth.overviewProgressEyebrow": "Avanzamento missioni", + "growth.overviewMissionsTitle": "Missioni growth", + "growth.overviewMissionsSummary": "{completed} di {count} completate", + "growth.overviewLoadingMissions": "Caricamento avanzamento missioni…", + "growth.overviewNoMissions": "Non esistono ancora missioni per questa repository.", + "growth.overviewCreateMission": "Crea una missione", + "growth.overviewBacklogEyebrow": "Interventi da svolgere", + "growth.overviewInterventionsTitle": "Interventi per stato", + "growth.overviewEditorialEyebrow": "Piano editoriale", + "growth.overviewContentTitle": "Contenuti per stato", + "growth.overviewScheduleEyebrow": "Programmazione in arrivo", + "growth.overviewNextSevenDays": "Prossimi 7 giorni", + "growth.overviewLoadingSummary": "Caricamento attività dello spazio…", + "growth.overviewNextEmpty": "Nessun contenuto è programmato nei prossimi 7 giorni.", + "growth.overviewActionsEyebrow": "Continua il lavoro", + "growth.overviewActionsTitle": "Azioni rapide dello spazio", + "growth.overviewActionsDescription": "Vai direttamente alla parte del piano growth di questa repository che richiede attenzione.", + "growth.status.proposed": "Proposti", + "growth.status.accepted": "Accettati", + "growth.status.dismissed": "Ignorati", + "growth.status.done": "Completati", + "growth.status.idea": "Idee", + "growth.status.draft": "Bozze", + "growth.status.ready": "Pronti", + "growth.status.scheduled": "Programmati", + "growth.status.published": "Pubblicati", + "growth.status.skipped": "Saltati", "goals.createTitle": "Imposta un obiettivo per la repository", "goals.createDescription": "Monitora un risultato misurabile e ricevi un piano basato sull'attività della repository.", "goals.repository": "Repository", diff --git a/src/styles.css b/src/styles.css index e70afea..bc2d83c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -12,3 +12,4 @@ @import "./styles/goals.css"; @import "./styles/growth/shell.css"; @import "./styles/growth/home.css"; +@import "./styles/growth/overview.css"; diff --git a/src/styles/growth/overview.css b/src/styles/growth/overview.css new file mode 100644 index 0000000..578ce51 --- /dev/null +++ b/src/styles/growth/overview.css @@ -0,0 +1,366 @@ +.mode-growth .growth-overview { + display: grid; + gap: 18px; +} + +.mode-growth .growth-overview-hero, +.mode-growth .growth-overview-card, +.mode-growth .growth-overview-actions { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: 14px; + box-shadow: var(--shadow); +} + +.mode-growth .growth-overview-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 28px; + padding: clamp(22px, 3vw, 34px); + background: + radial-gradient(460px 190px at 100% 0, var(--accent-soft), transparent 70%), + linear-gradient(145deg, color-mix(in srgb, var(--panel) 96%, var(--accent) 4%), var(--panel)); +} + +.mode-growth .growth-overview-identity { + display: flex; + align-items: center; + min-width: 0; + gap: 15px; +} + +.mode-growth .growth-overview-identity .avatar { + flex: 0 0 auto; + border: 1px solid var(--accent-border); + box-shadow: 0 0 0 4px var(--accent-faint); +} + +.mode-growth .growth-overview-identity > div { + min-width: 0; +} + +.mode-growth .growth-overview-identity span, +.mode-growth .growth-overview-card-heading span, +.mode-growth .growth-overview-actions > div > span { + color: var(--accent); + font-size: 9.5px; + font-weight: 850; + letter-spacing: .11em; + text-transform: uppercase; +} + +.mode-growth .growth-overview-identity h1 { + overflow: hidden; + margin: 6px 0 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: clamp(22px, 3vw, 34px); + letter-spacing: -.025em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mode-growth .growth-overview-identity p { + max-width: 680px; + margin: 7px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.mode-growth .growth-overview-repo-stats { + display: grid; + grid-template-columns: repeat(3, minmax(76px, auto)); + gap: 7px; + margin: 0; +} + +.mode-growth .growth-overview-repo-stats > div { + display: grid; + min-height: 65px; + align-content: space-between; + padding: 11px 12px; + background: color-mix(in srgb, var(--panel-2) 80%, transparent); + border: 1px solid var(--border-soft); + border-radius: 9px; +} + +.mode-growth .growth-overview-repo-stats dt, +.mode-growth .growth-overview-counters dt { + color: var(--muted); + font-size: 9px; + font-weight: 750; + letter-spacing: .06em; + text-transform: uppercase; +} + +.mode-growth .growth-overview-repo-stats dd { + overflow: hidden; + margin: 7px 0 0; + font-size: 15px; + font-weight: 780; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mode-growth .growth-overview-error { + padding: 10px 12px; + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--border)); + border-radius: 9px; + font-size: 12px; +} + +.mode-growth .growth-overview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 13px; +} + +.mode-growth .growth-overview-card { + min-width: 0; + padding: 18px; + box-shadow: 0 12px 32px rgba(0, 0, 0, .12); +} + +.mode-growth .growth-overview-missions, +.mode-growth .growth-overview-next { + grid-column: 1 / -1; +} + +.mode-growth .growth-overview-card-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 14px; + margin-bottom: 14px; +} + +.mode-growth .growth-overview-card-heading h2, +.mode-growth .growth-overview-actions h2 { + margin: 4px 0 0; + font-size: 16px; +} + +.mode-growth .growth-overview-card-heading > strong { + color: var(--muted); + font-size: 10.5px; + font-weight: 650; +} + +.mode-growth .growth-overview-goal-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); + gap: 9px; +} + +.mode-growth .growth-overview-goal { + --overview-goal-tone: var(--accent); + display: grid; + gap: 9px; + padding: 12px; + background: var(--panel-2); + border: 1px solid var(--border-soft); + border-radius: 10px; +} + +.mode-growth .growth-overview-goal.complete { + --overview-goal-tone: var(--success); +} + +.mode-growth .growth-overview-goal.overdue { + --overview-goal-tone: var(--danger); +} + +.mode-growth .growth-overview-goal-heading, +.mode-growth .growth-overview-goal-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.mode-growth .growth-overview-goal-heading strong { + font-size: 12px; +} + +.mode-growth .growth-overview-goal-heading > span { + color: var(--overview-goal-tone); + font-size: 12px; + font-weight: 800; +} + +.mode-growth .growth-overview-progress { + height: 5px; + overflow: hidden; + background: var(--panel-3); + border-radius: 99px; +} + +.mode-growth .growth-overview-progress > span { + display: block; + height: 100%; + background: var(--overview-goal-tone); + border-radius: inherit; +} + +.mode-growth .growth-overview-goal-meta { + color: var(--muted); + font-size: 9.5px; +} + +.mode-growth .growth-overview-empty { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px; + background: var(--panel-2); + border: 1px dashed var(--border); + border-radius: 10px; +} + +.mode-growth .growth-overview-empty p, +.mode-growth .growth-overview-loading, +.mode-growth .growth-overview-next-empty { + margin: 0; + color: var(--muted); + font-size: 11.5px; + line-height: 1.5; +} + +.mode-growth .growth-overview-empty .btn { + width: auto; + text-decoration: none; + white-space: nowrap; +} + +.mode-growth .growth-overview-counters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(86px, 1fr)); + gap: 8px; + margin: 0; +} + +.mode-growth .growth-overview-counters > div { + display: grid; + min-height: 65px; + align-content: space-between; + padding: 10px; + background: var(--panel-2); + border: 1px solid var(--border-soft); + border-radius: 9px; +} + +.mode-growth .growth-overview-counters dd { + margin: 8px 0 0; + font-size: 21px; + font-weight: 800; + line-height: 1; +} + +.mode-growth .growth-overview-next-list { + display: grid; + gap: 7px; +} + +.mode-growth .growth-overview-next-list article { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + padding: 10px 11px; + background: var(--panel-2); + border: 1px solid var(--border-soft); + border-radius: 9px; +} + +.mode-growth .growth-overview-next-list article > div { + display: grid; + gap: 3px; +} + +.mode-growth .growth-overview-next-list strong { + font-size: 11.5px; +} + +.mode-growth .growth-overview-next-list span, +.mode-growth .growth-overview-next-list time { + color: var(--muted); + font-size: 9.5px; +} + +.mode-growth .growth-overview-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 18px; +} + +.mode-growth .growth-overview-actions p { + margin: 5px 0 0; + color: var(--muted); + font-size: 11px; +} + +.mode-growth .growth-overview-actions nav { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 7px; +} + +.mode-growth .growth-overview-actions .btn { + width: auto; + text-decoration: none; +} + +@media (max-width: 820px) { + .mode-growth .growth-overview-hero, + .mode-growth .growth-overview-grid { + grid-template-columns: 1fr; + } + + .mode-growth .growth-overview-missions, + .mode-growth .growth-overview-next { + grid-column: 1; + } + + .mode-growth .growth-overview-repo-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .mode-growth .growth-overview-identity { + align-items: flex-start; + } + + .mode-growth .growth-overview-repo-stats { + grid-template-columns: 1fr; + } + + .mode-growth .growth-overview-repo-stats > div { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + min-height: 0; + } + + .mode-growth .growth-overview-repo-stats dd { + margin: 0; + } + + .mode-growth .growth-overview-card-heading, + .mode-growth .growth-overview-empty, + .mode-growth .growth-overview-actions { + align-items: flex-start; + flex-direction: column; + } + + .mode-growth .growth-overview-actions nav { + width: 100%; + justify-content: flex-start; + } +} diff --git a/src/types/growth.ts b/src/types/growth.ts new file mode 100644 index 0000000..6c63026 --- /dev/null +++ b/src/types/growth.ts @@ -0,0 +1,173 @@ +import type { GoalProposalFormat } from "./goals"; + +export const GROWTH_CHANNELS = [ + "x", + "linkedin", + "mastodon", + "bluesky", + "discussion", + "blog", +] as const; +export type GrowthChannel = (typeof GROWTH_CHANNELS)[number]; +export type GrowthContentChannel = GrowthChannel | "other"; + +export interface GrowthPillar { + id: string; + label: string; + weight: number; + description: string; +} + +export interface GrowthPostingWindow { + /** ISO weekday, from Monday (1) through Sunday (7). */ + weekday: number; + /** Local hour in the profile timezone, from 0 through 23. */ + hour: number; +} + +export type GrowthChannelSelection = Record; +export type GrowthCadence = Record; + +export interface GrowthProfile { + accountId: string; + repository: string; + language: string; + voice: string; + audience: string; + channels: GrowthChannelSelection; + cadence: GrowthCadence; + pillars: GrowthPillar[]; + hashtags: string[]; + avoid: string; + timezone: string; + postingWindows: GrowthPostingWindow[]; + color: string; + updatedAt: string; +} + +export const GROWTH_INTERVENTION_STATUSES = ["proposed", "accepted", "dismissed", "done"] as const; +export type GrowthInterventionStatus = (typeof GROWTH_INTERVENTION_STATUSES)[number]; +export type GrowthInterventionCategory = "product" | "community" | "engineering" | "marketing"; +export type GrowthInterventionOrigin = "ai" | "rule" | "manual"; + +export interface GrowthIntervention { + id: string; + accountId: string; + repository: string; + goalId: string | null; + category: GrowthInterventionCategory; + title: string; + action: string; + origin: GrowthInterventionOrigin; + ruleKey: string | null; + dedupeKey: string; + status: GrowthInterventionStatus; + createdAt: string; + updatedAt: string; +} + +export type GrowthContentPlanStatus = "draft" | "active" | "archived"; + +export interface GrowthContentPlan { + id: string; + accountId: string; + repository: string; + periodStart: string; + periodEnd: string; + cadence: GrowthCadence; + pillars: GrowthPillar[]; + status: GrowthContentPlanStatus; + generatedAt: string; + createdAt: string; +} + +export const GROWTH_CONTENT_ITEM_STATUSES = [ + "idea", + "draft", + "ready", + "scheduled", + "published", + "skipped", +] as const; +export type GrowthContentItemStatus = (typeof GROWTH_CONTENT_ITEM_STATUSES)[number]; + +export interface GrowthContentMedia { + assetId?: string; + url?: string; + kind: "image" | "video"; + alt: string; + caption?: string; +} + +export interface GrowthContentItem { + id: string; + accountId: string; + repository: string; + planId: string | null; + interventionId: string | null; + goalIds: string[]; + channel: GrowthContentChannel; + format: GoalProposalFormat; + pillar: string; + angle: string; + title: string; + summary: string; + body: string; + threadPosts: string[]; + media: GrowthContentMedia[]; + sources: string[]; + status: GrowthContentItemStatus; + scheduledFor: string | null; + publishedAt: string | null; + publishedUrl: string | null; + generatedAt: string | null; + generationVersion: number; + evergreen: 0 | 1; + createdAt: string; + updatedAt: string; +} + +export type GrowthAssetKind = "image" | "video"; +export type GrowthAssetOrigin = "upload" | "readme" | "website" | "generated"; + +export interface GrowthAsset { + id: string; + accountId: string; + repository: string; + kind: GrowthAssetKind; + origin: GrowthAssetOrigin; + path: string | null; + url: string | null; + title: string; + alt: string; + width: number | null; + height: number | null; + cardTemplate: string | null; + cardData: Record | null; + createdAt: string; +} + +export type GrowthPerformanceWindow = "48h" | "7d"; + +export interface GrowthPerformanceMetrics { + starsDelta?: number; + forksDelta?: number; + closedPrsDelta?: number; + releaseDownloadsDelta?: number; + [metric: string]: number | undefined; +} + +export interface GrowthContentPerformance { + accountId: string; + contentId: string; + window: GrowthPerformanceWindow; + measuredAt: string; + metrics: GrowthPerformanceMetrics; +} + +export interface GrowthWorkspaceSummary { + repository: string; + interventionsByStatus: Record; + contentItemsByStatus: Record; + nextSevenDays: GrowthContentItem[]; +} diff --git a/src/utils/growthWorkspace.ts b/src/utils/growthWorkspace.ts new file mode 100644 index 0000000..580b53f --- /dev/null +++ b/src/utils/growthWorkspace.ts @@ -0,0 +1,30 @@ +import type { RepositoryGoal } from "../types/goals"; +import { calculateGoalProgress, type GoalProgress } from "./goals"; + +export interface GrowthWorkspaceGoalProgress { + goal: RepositoryGoal; + progress: GoalProgress; +} + +export interface GrowthWorkspaceGoalSummary { + goals: GrowthWorkspaceGoalProgress[]; + completed: number; + overdue: number; +} + +/** Summarizes mission progress for one repository workspace. */ +export function buildGrowthWorkspaceGoalSummary( + goals: RepositoryGoal[], + now = new Date(), +): GrowthWorkspaceGoalSummary { + const summarizedGoals = goals.map((goal) => ({ + goal, + progress: calculateGoalProgress(goal, now), + })); + + return { + goals: summarizedGoals, + completed: summarizedGoals.filter(({ progress }) => progress.completed).length, + overdue: summarizedGoals.filter(({ progress }) => progress.overdue).length, + }; +} diff --git a/tests/components/growth/GrowthWorkspaceOverview.test.ts b/tests/components/growth/GrowthWorkspaceOverview.test.ts new file mode 100644 index 0000000..ff8b84e --- /dev/null +++ b/tests/components/growth/GrowthWorkspaceOverview.test.ts @@ -0,0 +1,110 @@ +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GrowthWorkspaceOverview } from "../../../src/components/growth/GrowthWorkspaceOverview"; +import { I18nProvider } from "../../../src/i18n/I18nProvider"; +import type { GhRepo } from "../../../src/types/github"; +import type { RepositoryGoal } from "../../../src/types/goals"; + +const hooks = vi.hoisted(() => ({ + useGoals: vi.fn(), +})); + +vi.mock("../../../src/hooks/useGoals", () => ({ + useGoals: hooks.useGoals, +})); + +const repository: GhRepo = { + nameWithOwner: "acme/rocket", + name: "rocket", + owner: { login: "acme", avatarUrl: "https://example.com/acme.png" }, + description: "A fast repository", + stargazerCount: 1250, + forkCount: 42, + primaryLanguage: { name: "TypeScript" }, + updatedAt: "2026-09-04T00:00:00.000Z", + pushedAt: "2026-09-04T00:00:00.000Z", + visibility: "PUBLIC", + isPrivate: false, + isArchived: false, + isFork: false, + url: "https://github.com/acme/rocket", +}; + +function goal(): RepositoryGoal { + return { + id: "goal-1", + accountId: "account-a", + repository: repository.nameWithOwner, + metric: "stars", + targetValue: 100, + currentValue: 50, + deadline: "2099-12-31", + createdAt: "2026-09-04T00:00:00.000Z", + updatedAt: "2026-09-04T00:00:00.000Z", + suggestions: [], + suggestionsGeneratedAt: null, + aiEnabled: true, + }; +} + +let container: HTMLDivElement; +let root: Root; + +async function renderOverview() { + await act(async () => { + root.render(createElement( + I18nProvider, + null, + createElement( + MemoryRouter, + { initialEntries: ["/growth/r/acme/rocket"] }, + createElement(GrowthWorkspaceOverview, { + accountId: "account-a", + enabled: true, + repository: repository.nameWithOwner, + repos: [repository], + }), + ), + )); + await Promise.resolve(); + }); +} + +beforeEach(() => { + localStorage.clear(); + hooks.useGoals.mockReset(); + container = document.createElement("div"); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); +}); + +describe("GrowthWorkspaceOverview", () => { + it("renders repository identity and the empty mission and activity states", async () => { + hooks.useGoals.mockReturnValue({ goals: [], loading: false, error: "", refresh: vi.fn() }); + + await renderOverview(); + + expect(container.textContent).toContain("acme/rocket"); + expect(container.textContent).toContain("A fast repository"); + expect(container.textContent).toContain("No missions exist for this repository yet."); + expect([...container.querySelectorAll(".growth-overview-counters dd")].map((node) => node.textContent)) + .toEqual(Array(10).fill("0")); + expect(container.textContent).toContain("No content is scheduled for the next 7 days."); + }); + + it("renders calculated progress when the repository has a mission", async () => { + hooks.useGoals.mockReturnValue({ goals: [goal()], loading: false, error: "", refresh: vi.fn() }); + + await renderOverview(); + + expect(container.textContent).toContain("0 of 1 completed"); + expect(container.textContent).toContain("50%"); + expect(container.textContent).toContain("50 / 100"); + expect(container.textContent).not.toContain("No missions exist for this repository yet."); + }); +}); diff --git a/tests/utils/growthWorkspace.test.ts b/tests/utils/growthWorkspace.test.ts new file mode 100644 index 0000000..52cc9e0 --- /dev/null +++ b/tests/utils/growthWorkspace.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { RepositoryGoal } from "../../src/types/goals"; +import { buildGrowthWorkspaceGoalSummary } from "../../src/utils/growthWorkspace"; + +function goal(id: string, currentValue: number, targetValue: number, deadline: string): RepositoryGoal { + return { + id, + accountId: "account-a", + repository: "acme/rocket", + metric: "stars", + targetValue, + currentValue, + deadline, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + suggestions: [], + suggestionsGeneratedAt: null, + aiEnabled: true, + }; +} + +describe("buildGrowthWorkspaceGoalSummary", () => { + it("returns an empty summary for a workspace without missions", () => { + expect(buildGrowthWorkspaceGoalSummary([], new Date("2026-09-04T00:00:00.000Z"))).toEqual({ + goals: [], + completed: 0, + overdue: 0, + }); + }); + + it("reuses bounded goal progress and counts completed and overdue missions", () => { + const summary = buildGrowthWorkspaceGoalSummary([ + goal("active", 4, 10, "2026-09-10"), + goal("complete", 12, 10, "2026-09-01"), + goal("overdue", 2, 10, "2026-09-03"), + ], new Date("2026-09-04T00:00:00.000Z")); + + expect(summary.completed).toBe(1); + expect(summary.overdue).toBe(1); + expect(summary.goals.map(({ progress }) => progress.percentage)).toEqual([40, 100, 20]); + }); +}); From 26cd295eba9b5d190e9feced64f2e412fb65224c Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 10:11:23 +0200 Subject: [PATCH 13/54] feat(growth): polish the Growth Studio shell --- README.md | 10 +- .../docs/GS_FEATURE_MATRIX.md | 12 ++- growth-studio-project/tasks/GS-020.md | 4 +- growth-studio-project/tasks/GS-022.md | 6 +- growth-studio-project/tasks/GS-023.md | 3 +- growth-studio-project/tasks/GS-025.md | 4 +- growth-studio-project/tasks/PROGRESS.md | 2 +- src/components/common/RepositoryPicker.tsx | 27 +++++- src/components/growth/GrowthSidebar.tsx | 11 ++- src/components/growth/GrowthStudioApp.tsx | 22 ++++- src/components/growth/GrowthTopBar.tsx | 4 + src/i18n/it.ts | 36 +++---- src/styles/growth/shell.css | 35 ++++++- .../common/RepositoryPicker.test.ts | 93 +++++++++++++++++++ 14 files changed, 223 insertions(+), 46 deletions(-) create mode 100644 tests/components/common/RepositoryPicker.test.ts diff --git a/README.md b/README.md index 6e79b67..9214967 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,15 @@ The dashboard pulls data from the GitHub REST and GraphQL APIs and organizes it - **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 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). +- **Growth Studio** — repository-focused missions for stars, forks, closed PRs, and release downloads, with progress tracking and activity-aware AI action plans. + +## Growth Studio + +Growth Studio is Gitdeck's focused workspace for turning repository goals and signals into measurable growth work. It organizes each repository around missions today, with interventions, editorial planning, and review workflows being added within the same dedicated shell. + +Open **Growth Studio** from the main dashboard navigation. It launches `/growth` in a new browser window so the growth workspace can stay open alongside the dashboard. + +> **Screenshot placeholder:** Growth Studio home and repository workspace preview will be added before release. ### Per-repository view diff --git a/growth-studio-project/docs/GS_FEATURE_MATRIX.md b/growth-studio-project/docs/GS_FEATURE_MATRIX.md index 33aa06c..8091c36 100644 --- a/growth-studio-project/docs/GS_FEATURE_MATRIX.md +++ b/growth-studio-project/docs/GS_FEATURE_MATRIX.md @@ -20,10 +20,12 @@ Authoritative inventory of Growth Studio capabilities. Statuses: `EXISTING` | Missions panel hosting the existing goals UI | `src/components/growth/GrowthStudioApp.tsx`, `src/hooks/useGoals.ts`, `src/components/views/GoalsView.tsx` | DONE | GS-013 | | Growth home: repositories with profiles or goals, quick stats | `src/components/growth/GrowthHome.tsx`, `src/utils/growthHome.ts`, `/growth` | DONE | GS-014 | | Workspace overview per repository | `src/components/growth/GrowthWorkspaceOverview.tsx`, `src/types/growth.ts`, `src/api/growth.ts`, `/growth/r/:owner/:repo` | DONE | GS-015 | -| Shell i18n, responsive layout, theme parity | shell components | PLANNED | GS-016 | -| Growth store schema (profiles, interventions, plans, items, assets, performance) | `server/growth/store.ts` | PLANNED | GS-020 | -| One-shot migration of legacy suggestions and proposals | `server/growth/store.ts` | PLANNED | GS-021 | -| Growth API routes | `server/routes/growth.ts`, `api/growth.ts` | PLANNED | GS-022 | +| Shell i18n, responsive layout, theme parity, and keyboard dismissal | `src/components/growth/`, `src/components/common/RepositoryPicker.tsx`, `src/styles/growth/`, `src/i18n/en.ts`, `src/i18n/it.ts` | DONE | GS-016 | +| Growth Studio introduction and screenshot placeholder | `README.md` | DONE | GS-016 | +| Growth store schema (profiles, interventions, plans, items, assets, performance) | `src/server/growth/store.ts`, `src/types/growth.ts` | PLANNED | GS-020 | +| One-shot migration of legacy suggestions and proposals | `src/server/growth/store.ts` | PLANNED | GS-021 | +| Growth API routes and account-wide workspace summaries | `src/server/routes/growth.ts`, `src/api/growth.ts` | PLANNED | GS-022 | +| Shared repository signal collection with SSRF-guarded sources | `src/server/growth/signals.ts` | PLANNED | GS-023 | | Interventions backlog with statuses and manual creation | `/growth/r/:owner/:repo/interventions` | PLANNED | GS-023 | | Content items list and drawer with copy actions | Interventions and Calendar panels | PLANNED | GS-024 | | Library panel: sources, profile (voice, audience, channels), pillars, cadence | `/growth/r/:owner/:repo/library` | PLANNED | GS-025 | @@ -45,4 +47,4 @@ Authoritative inventory of Growth Studio capabilities. Statuses: `EXISTING` | Unified calendar with per-repository colours and filters | `/growth/calendar` | PLANNED | Phase 6 | | Multi-repository deconfliction | planner | PLANNED | Phase 6 | | Growth settings (defaults, timezone) | `/growth/settings` | PLANNED | Phase 6 | -| README, CHANGELOG and screenshots | `README.md`, `CHANGELOG.md` | PLANNED | Phase 6 | +| Release documentation, CHANGELOG and final screenshots | `README.md`, `CHANGELOG.md` | PLANNED | Phase 6 | diff --git a/growth-studio-project/tasks/GS-020.md b/growth-studio-project/tasks/GS-020.md index f678445..838b625 100644 --- a/growth-studio-project/tasks/GS-020.md +++ b/growth-studio-project/tasks/GS-020.md @@ -2,8 +2,8 @@ **Phase:** Phase 2 — Data model -- Create `src/server/growth/store.ts` with idempotent schema creation for `growth_profiles`, `growth_interventions`, `content_plans`, `content_items`, `growth_assets` and `content_performance` exactly as in plan section 5.2, plus indexes on (`account_id`, `repository`) and on `content_items(account_id, scheduled_for)`. -- Implement typed CRUD: profiles get or upsert (with defaults: pillars Release, Educational, Community, Behind the scenes, Milestones; cadence x 3, linkedin 1, mastodon 3; language from the profile or `en`); interventions list, create, update status; content items list with filters (repository, status, date range), create, update, reschedule, mark published, delete; plans create and archive. +- Create `src/server/growth/store.ts` with idempotent schema creation for `growth_profiles`, `growth_interventions`, `content_plans`, `content_items`, `growth_assets` and `content_performance` exactly as in plan section 5.2, plus indexes on (`account_id`, `repository`) and on `content_items(account_id, scheduled_for)`. Extend the camelCase transport entities already defined in `src/types/growth.ts` rather than introducing duplicate store-facing client types. +- Implement typed CRUD: profiles get or upsert (with the defaults settled in D-019); interventions list, create, update status; content items list with filters (repository, status, date range), create, update, reschedule, mark published, delete; plans create and archive. - Enforce in the store that `ready`, `scheduled` and `published` require `media.length > 0`; throw a typed `MediaRequiredError`. - Tests under `tests/server/growthStore.test.ts` using an isolated database path (see `closeDatabase` in `src/server/sqlite.ts`). diff --git a/growth-studio-project/tasks/GS-022.md b/growth-studio-project/tasks/GS-022.md index e4ee6d7..29a0db2 100644 --- a/growth-studio-project/tasks/GS-022.md +++ b/growth-studio-project/tasks/GS-022.md @@ -2,13 +2,13 @@ **Phase:** Phase 2 — Data model -- Create `src/server/routes/growth.ts` registered from `routes/index.ts`, prefix `/api/growth/`: `GET workspace/:owner/:repo` (summary for the overview), `GET|PUT profiles/:owner/:repo`, `GET|POST interventions`, `PATCH interventions/:id`, `GET|POST content`, `PATCH content/:id` (fields, status, schedule), `POST content/:id/published` (url), `DELETE content/:id`. Every handler validates input like `routes/goals.ts` and scopes by the active account. -- Create `src/api/growth.ts` with typed fetchers and replace the GS-015 client stub. +- Create `src/server/routes/growth.ts` registered from `src/server/routes/index.ts`, prefix `/api/growth/`: `GET workspaces` (account-wide Home summaries for persisted profiles or active goals), `GET workspace/:owner/:repo` (summary for the overview), `GET|PUT profiles/:owner/:repo`, `GET|POST interventions`, `PATCH interventions/:id`, `GET|POST content`, `PATCH content/:id` (fields, status, schedule), `POST content/:id/published` (url), `DELETE content/:id`. Every handler validates input like `src/server/routes/goals.ts` and scopes by the active account. +- Extend the typed fetcher stub in `src/api/growth.ts`; wire `GrowthWorkspaceOverview` to real counters and replace `GrowthHome`'s goal-only workspace discovery with `GET /api/growth/workspaces` without changing its fallback repository identity behavior. - Tests in `tests/server/growthRoutes.test.ts` for validation errors and account scoping, following the style of existing server tests. Acceptance: -- Overview counters read real data; all routes reject invalid bodies with 400. +- Home includes profile-only workspaces, overview counters read real data, and all routes reject invalid bodies with 400. ## Completion record diff --git a/growth-studio-project/tasks/GS-023.md b/growth-studio-project/tasks/GS-023.md index c588cfe..152fd46 100644 --- a/growth-studio-project/tasks/GS-023.md +++ b/growth-studio-project/tasks/GS-023.md @@ -3,7 +3,8 @@ **Phase:** Phase 2 — Data model - Route `/growth/r/:owner/:repo/interventions`: list interventions grouped by status (proposed, accepted, done, dismissed collapsed), with category chip, origin badge, linked goal, and actions accept, dismiss, mark done. Filters by category and origin. -- "Generate interventions" button reuses `generateGoalSuggestions` logic through a new `POST /api/growth/interventions/generate` for a repository (with or without goals; when no goal exists, the prompt is goal-less and grounded in signals only), storing results as interventions with dedupe. +- Create `src/server/growth/signals.ts` as the shared repository-signal collector described in plan section 5.3. Move the existing README, release, repository-source, and SSRF-guarded website fetchers out of `src/server/goals.ts` without changing legacy behavior, then add recent commits, available star history, and goal progress for Growth Studio consumers. +- "Generate interventions" button reuses `generateGoalSuggestions` logic through a new `POST /api/growth/interventions/generate` for a repository (with or without goals; when no goal exists, the prompt is goal-less and grounded in the shared repository signals), storing results as interventions with dedupe. - Manual creation form: title, action, category. - Content items linked to an intervention are listed inline with their status. - Styles in `src/styles/growth/interventions.css`; i18n in both locales. diff --git a/growth-studio-project/tasks/GS-025.md b/growth-studio-project/tasks/GS-025.md index 7f0f6ee..4ad9db1 100644 --- a/growth-studio-project/tasks/GS-025.md +++ b/growth-studio-project/tasks/GS-025.md @@ -2,13 +2,13 @@ **Phase:** Phase 2 — Data model -- Route `/growth/r/:owner/:repo/library` with sections: Sources (move `RepositoryContentSources` here unchanged in behavior), Profile (language, voice, audience, hashtags, avoid list, timezone, colour), Channels (toggles), Cadence (posts per week per channel), Pillars (editable list with weight and description, defaults from the store), Posting windows (weekday and hour list). +- Replace the shipped placeholder at `/growth/r/:owner/:repo/library` with sections: Sources (reuse `RepositoryContentSources` here unchanged in behavior), Profile (language, voice, audience, hashtags, avoid list, timezone, colour), Channels (toggles), Cadence (posts per week per channel), Pillars (editable list with weight and description, defaults from the store), Posting windows (weekday and hour list). - Save through `PUT /api/growth/profiles/:owner/:repo`; validate on the server (weights 0–100, cadence 0–14, known channels, IANA timezone check via `Intl.DateTimeFormat`). - Pure validation and normalization in `src/utils/growth/profile.ts` with tests in `tests/utils/growth/profile.test.ts`. Acceptance: -- Profile round-trips; invalid values are rejected with clear messages; the Missions panel no longer shows the sources widget. +- Profile round-trips; invalid values are rejected with clear messages; the Missions panel no longer exposes its source-library action. ## Completion record diff --git a/growth-studio-project/tasks/PROGRESS.md b/growth-studio-project/tasks/PROGRESS.md index fd4fd1f..e2b5254 100644 --- a/growth-studio-project/tasks/PROGRESS.md +++ b/growth-studio-project/tasks/PROGRESS.md @@ -19,7 +19,7 @@ phase-closing tasks) are appended with `PENDING` rows and matching task files. | GS-013 | COMPLETED | Added the repository-scoped Missions panel with locked goal creation, refreshable account-aware loading, and new-window AI preferences | VALIDATION OK; typecheck; 31 test files and 161 tests; production build; useGoals cancellation and refresh tests; git diff --check | 2026-09-04 | | GS-014 | COMPLETED | Added the Growth home with goal-backed repository cards, quick stats, fallback identities, and a remaining-repository starter picker | VALIDATION OK; typecheck; 32 test files and 164 tests; production build; Growth home summary and account-wide goal hook tests; git diff --check | 2026-09-04 | | GS-015 | COMPLETED | Added the repository workspace overview with mission progress, typed phase 2 activity summaries, upcoming content, and panel shortcuts | VALIDATION OK; typecheck; 34 test files and 168 tests; production build; overview rendering and goal summary tests; git diff --check | 2026-09-04 | -| GS-016 | PENDING | — | — | — | +| GS-016 | COMPLETED | Polished responsive shell and Missions layouts, hardened keyboard dismissal, refined Italian copy, documented Growth Studio, and aligned phase 2 tasks | VALIDATION OK; typecheck; 35 test files and 169 tests; production build; 60-screen Chromium audit at 1440, 1024, and 390 in dark and light themes with no overflow; keyboard focus and Escape checks; 715 locale keys matched; phase 2 files and PENDING rows verified; git diff --check | 2026-09-04 | | GS-020 | PENDING | — | — | — | | GS-021 | PENDING | — | — | — | | GS-022 | PENDING | — | — | — | diff --git a/src/components/common/RepositoryPicker.tsx b/src/components/common/RepositoryPicker.tsx index 045d540..393efc7 100644 --- a/src/components/common/RepositoryPicker.tsx +++ b/src/components/common/RepositoryPicker.tsx @@ -62,10 +62,30 @@ export function RepositoryPicker({ repos, value, placeholder, onChange }: Reposi setOpen(true); }} onKeyDown={(event) => { - if (event.key === "ArrowDown") { event.preventDefault(); setOpen(true); setActiveIndex((index) => Math.min(index + 1, matches.length - 1)); } - if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((index) => Math.max(index - 1, 0)); } + if (event.key === "ArrowDown") { + event.preventDefault(); + if (!open) { + setOpen(true); + setActiveIndex(0); + } else { + setActiveIndex((index) => Math.min(index + 1, matches.length - 1)); + } + } + if (event.key === "ArrowUp") { + event.preventDefault(); + if (!open) { + setOpen(true); + setActiveIndex(Math.max(matches.length - 1, 0)); + } else { + setActiveIndex((index) => Math.max(index - 1, 0)); + } + } if (event.key === "Enter" && open && matches[activeIndex]) { event.preventDefault(); select(matches[activeIndex]); } - if (event.key === "Escape") setOpen(false); + if (event.key === "Escape" && open) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } }} /> @@ -79,6 +99,7 @@ export function RepositoryPicker({ repos, value, placeholder, onChange }: Reposi +