Compare commits

...

26 Commits

Author SHA1 Message Date
dc1cc47f18 chore: readable docker up
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m58s
2026-03-19 08:21:40 +01:00
3d4803f975 perf(realtime+data): implement perf-data-optimization and perf-realtime-scale
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m33s
## perf-data-optimization
- Add @@index([name]) on User model (migration)
- Add WEATHER_HISTORY_LIMIT=90 constant, apply take/orderBy on weather history queries
- Replace deep includes with explicit select on all 6 list service queries
- Add unstable_cache layer with revalidateTag on all list service functions
- Add cache-tags.ts helpers (sessionTag, sessionsListTag, userStatsTag)
- Invalidate sessionsListTag in all create/delete Server Actions

## perf-realtime-scale
- Create src/lib/broadcast.ts: generic createBroadcaster factory with shared polling
  (one interval per active session, starts on first subscriber, stops on last)
- Migrate all 6 SSE routes to use createBroadcaster — removes per-connection setInterval
- Add broadcastToXxx() calls in all Server Actions after mutations for immediate push
- Add SESSIONS_PAGE_SIZE=20, pagination on sessions page with loadMoreSessions action
- Add "Charger plus" button with loading state and "X sur Y" counter in WorkshopTabs

## Tests
- Add 19 unit tests for broadcast.ts (polling lifecycle, userId filtering,
  formatEvent, error resilience, session isolation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:30:54 +01:00
5b45f18ad9 chore: add Vitest for testing and coverage support
- Introduced new test scripts in package.json: "test", "test:watch", and "test:coverage".
- Added Vitest and vite-tsconfig-paths as dependencies for improved testing capabilities.
- Updated pnpm-lock.yaml to reflect new dependencies and their versions.
2026-03-10 08:38:40 +01:00
f9ed732f1c test: add unit test coverage for services and lib
- 255 tests across 14 files (was 70 tests in 4 files)
- src/services/__tests__: auth (registerUser, updateUserPassword, updateUserProfile), okrs (calculateOKRProgress, createOKR, updateKeyResult, updateOKR), teams (createTeam, addTeamMember, isAdminOfUser, getTeamMemberIdsForAdminTeams, getUserTeams), weather (getPreviousWeatherEntriesForUsers, shareWeatherSessionToTeam, getWeatherSessionsHistory), workshops (createSwotItem, duplicateSwotItem, updateAction, createMotivatorSession, updateCardInfluence, addGifMoodItem, shareGifMoodSessionToTeam, getLatestEventTimestamp, cleanupOldEvents)
- src/lib/__tests__: date-utils, weather-utils, okr-utils, gravatar, workshops, share-utils
- Update vitest coverage to include src/lib/**

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 08:37:32 +01:00
a8c05aa841 perf(quick-wins): batch collaborator resolution, debounce SSE refresh, loading states
- Eliminate N+1 on resolveCollaborator: add batchResolveCollaborators() in
  auth.ts (2 DB queries max regardless of session count), update all 4
  workshop services to use post-batch mapping
- Debounce router.refresh() in useLive.ts (300ms) to group simultaneous
  SSE events and avoid cascade re-renders
- Call cleanupOldEvents fire-and-forget in createEvent to purge old SSE
  events inline without blocking the response
- Add loading.tsx skeletons on /sessions and /users matching actual page
  layout (PageHeader + content structure)
- Lazy-load ShareModal via next/dynamic in BaseSessionLiveWrapper to reduce
  initial JS bundle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 08:07:22 +01:00
2d266f89f9 feat(perf): implement performance optimizations for session handling
- Introduced a new configuration file `config.yaml` for specifying project context and artifact rules.
- Added `.openspec.yaml` files for tracking changes related to performance improvements.
- Created design documents outlining the context, goals, decisions, and migration plans for optimizing session performance.
- Proposed changes include batching database queries, debouncing event refreshes, purging old events, and implementing loading states for better user experience.
- Added tasks and specifications to ensure proper implementation and validation of the new features.

These enhancements aim to improve the scalability and responsiveness of the application during collaborative sessions.
2026-03-10 08:06:47 +01:00
6baa9bfada feat(opsx): add new commands for workflow management
- Introduced `OPSX: Apply` to implement tasks from OpenSpec changes.
- Added `OPSX: Archive` for archiving completed changes in the experimental workflow.
- Created `OPSX: Explore` for a thinking partner mode to investigate ideas and clarify requirements.
- Implemented `OPSX: Propose` to generate change proposals and associated artifacts in one step.
- Developed skills for `openspec-apply-change` and `openspec-archive-change` to facilitate task implementation and archiving processes.

These additions enhance the workflow capabilities and provide structured approaches for managing changes within the OpenSpec framework.
2026-03-09 21:31:05 +01:00
f2c1b195b3 Fix UserStats typing in users page counters
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m58s
2026-03-04 17:04:09 +01:00
367eea6ee8 Persist sessions view mode in localStorage 2026-03-04 17:04:03 +01:00
dcc769a930 fix(users): include all workshop types in user stats 2026-03-04 08:44:07 +01:00
313ad53e2e refactor(weather): move top disclosures below board
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m3s
2026-03-04 08:39:19 +01:00
8bff21bede feat(weather): show trend indicators on team averages 2026-03-04 08:34:23 +01:00
4aea17124e feat(ui): allow per-disclosure emoji icons 2026-03-04 08:32:19 +01:00
db7a0cef96 refactor(ui): unify low-level controls and expand design system
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
2026-03-03 15:50:15 +01:00
9a43980412 refactor: extract Icons and InlineFormActions UI components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m28s
- Add Icons.tsx: IconEdit, IconTrash, IconDuplicate, IconPlus, IconCheck, IconClose
- Add InlineFormActions.tsx: unified Annuler/Ajouter-Enregistrer button pair
- Replace inline SVGs in SwotCard, YearReviewCard, WeeklyCheckInCard, SwotQuadrant,
  YearReviewSection, WeeklyCheckInSection, EditableTitle, Modal, GifMoodCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:25:35 +01:00
09a849279b refactor: add SessionPageHeader and apply to all 6 session detail pages
- Create SessionPageHeader component (breadcrumb + editable title + collaborator + badges + date)
- Embed UPDATE_FN map internally, keyed by workshopType — no prop drilling
- Replace duplicated header blocks in sessions, motivators, year-review, weather, weekly-checkin, gif-mood

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:15:43 +01:00
b1ba43fd30 refactor: merge 6 EditableTitle wrappers into one file
Replace EditableSessionTitle, EditableMotivatorTitle, EditableYearReviewTitle,
EditableWeatherTitle, EditableWeeklyCheckInTitle, EditableGifMoodTitle individual
files with a single EditableTitles.tsx using spread props. Same public API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:06:45 +01:00
2e00522bfc feat: add PageHeader component and centralize page spacing
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m1s
- Create reusable PageHeader component (emoji + title + subtitle + actions)
- Use PageHeader in sessions, teams, users, objectives pages
- Centralize vertical padding in layout (py-6) and remove per-page py-* values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:01:07 +01:00
66ac190c15 feat: redesign sessions dashboard with multi-view layout and sortable table
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
- Redesign session cards with colored left border (Figma-style), improved
  visual hierarchy, hover states, and stats in footer
- Add 4 switchable view modes: grid, list, sortable table, and timeline
- Table view: unified flat table with clickable column headers for sorting
  (Type, Titre, Créateur, Participant, Stats, Date)
- Add Créateur column showing the workshop owner with Gravatar avatar
- Widen Type column to 160px for better readability
- Improve tabs navigation with pill-shaped active state and shadow
- Fix TypeFilterDropdown to exclude 'Équipe' from type list
- Make filter tabs visually distinct with bg-card + border + shadow-sm
- Split WorkshopTabs.tsx into 4 focused modules:
  workshop-session-types.ts, workshop-session-helpers.ts,
  SessionCard.tsx, WorkshopTabs.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 13:54:23 +01:00
7be296231c feat: add weather trend chart showing indicator averages over time
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
Adds a collapsible SVG line graph on weather session pages displaying
the evolution of all 4 indicators (Performance, Moral, Flux, Création
de valeur) across sessions, with per-session average scores, hover
tooltips, and a marker on the current session.

Also fixes pre-existing lint errors: non-null assertion on optional
chain in Header and eslint-disable for intentional hydration pattern
in ThemeToggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:45:19 +01:00
c3b653601c fix: notes patching in weather
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m58s
2026-03-03 11:18:11 +01:00
8de4c1985f feat: update gif mood board column options to 4/5/6
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m58s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:14:36 +01:00
766f3d5a59 feat: add GIF Mood Board workshop
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m5s
- New workshop where each team member shares up to 5 GIFs with notes to express their weekly mood
- Per-user week rating (1-5 stars) visible next to each member's section
- Masonry-style grid with adjustable column count (3/4/5) toggle
- Handwriting font (Caveat) for GIF notes
- Full real-time collaboration via SSE
- Clean migration (add_gif_mood_workshop) safe for production deploy
- DB backup via cp before each migration in docker-entrypoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:04:56 +01:00
7c68fb81e3 fix: prevent ThemeToggle hydration mismatch by deferring icon render
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m32s
Server doesn't know localStorage theme, so defer emoji rendering until
after mount to avoid server/client text mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:16:31 +01:00
9298eef0cb refactor: make Header a server component to avoid auth flash on load
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Move session check from client-side useSession() to server-side auth(),
so the authenticated state is known at initial render. Extract interactive
parts (ThemeToggle, UserMenu, WorkshopsDropdown, NavLinks) into small
client components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:14:27 +01:00
a10205994c refactor: improve team management, OKRs, and session components 2026-02-25 17:29:40 +01:00
207 changed files with 14761 additions and 3369 deletions

View File

@@ -0,0 +1,152 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,157 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,173 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,106 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -0,0 +1,152 @@
---
name: /opsx-apply
id: opsx-apply
category: Workflow
description: Implement tasks from an OpenSpec change (Experimental)
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,157 @@
---
name: /opsx-archive
id: opsx-archive
category: Workflow
description: Archive a completed change in the experimental workflow
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,173 @@
---
name: /opsx-explore
id: opsx-explore
category: Workflow
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,106 @@
---
name: /opsx-propose
id: opsx-propose
category: Workflow
description: Propose a new change - create it and generate all artifacts in one step
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -20,4 +20,4 @@ jobs:
AUTH_URL: ${{ vars.AUTH_URL }} AUTH_URL: ${{ vars.AUTH_URL }}
DATA_VOLUME_PATH: ${{ vars.DATA_VOLUME_PATH }} DATA_VOLUME_PATH: ${{ vars.DATA_VOLUME_PATH }}
run: | run: |
docker compose up -d --build BUILDKIT_PROGRESS=plain docker compose up -d --build

View File

@@ -6,4 +6,3 @@
"printWidth": 100, "printWidth": 100,
"plugins": [] "plugins": []
} }

View File

@@ -28,6 +28,7 @@ pnpm prisma studio # Open DB GUI
## Architecture Overview ## Architecture Overview
### Workshop Types ### Workshop Types
There are 5 workshop types: `swot` (sessions), `motivators`, `year-review`, `weekly-checkin`, `weather`. There are 5 workshop types: `swot` (sessions), `motivators`, `year-review`, `weekly-checkin`, `weather`.
**Single source of truth**: `src/lib/workshops.ts` exports `WORKSHOPS`, `WORKSHOP_BY_ID`, and helpers. Every place that lists or routes workshop types must use this file. **Single source of truth**: `src/lib/workshops.ts` exports `WORKSHOPS`, `WORKSHOP_BY_ID`, and helpers. Every place that lists or routes workshop types must use this file.
@@ -58,6 +59,7 @@ src/
``` ```
### Real-Time Collaboration (SSE) ### Real-Time Collaboration (SSE)
Each workshop has `/api/[path]/[id]/subscribe` — a GET route that opens a `ReadableStream` (SSE). The server polls the DB every 1 second for new events and pushes them to connected clients. Server Actions write events to the DB after mutations. Each workshop has `/api/[path]/[id]/subscribe` — a GET route that opens a `ReadableStream` (SSE). The server polls the DB every 1 second for new events and pushes them to connected clients. Server Actions write events to the DB after mutations.
Client side: `useLive` hook (`src/hooks/useLive.ts`) connects to the subscribe endpoint with `EventSource`, filters out events from the current user (to avoid duplicates), and calls `router.refresh()` on incoming events. Client side: `useLive` hook (`src/hooks/useLive.ts`) connects to the subscribe endpoint with `EventSource`, filters out events from the current user (to avoid duplicates), and calls `router.refresh()` on incoming events.
@@ -65,21 +67,26 @@ Client side: `useLive` hook (`src/hooks/useLive.ts`) connects to the subscribe e
`BaseSessionLiveWrapper` (`src/components/collaboration/`) is the shared wrapper component that wires `useLive`, `CollaborationToolbar`, and `ShareModal` for all workshop session pages. `BaseSessionLiveWrapper` (`src/components/collaboration/`) is the shared wrapper component that wires `useLive`, `CollaborationToolbar`, and `ShareModal` for all workshop session pages.
### Shared Permission System ### Shared Permission System
`createSessionPermissionChecks(model)` in `src/services/session-permissions.ts` returns `canAccess`, `canEdit`, `canDelete` for any Prisma model that follows the session shape (has `userId` + `shares` relation). Team admins have implicit access to their members' sessions. `createSessionPermissionChecks(model)` in `src/services/session-permissions.ts` returns `canAccess`, `canEdit`, `canDelete` for any Prisma model that follows the session shape (has `userId` + `shares` relation). Team admins have implicit access to their members' sessions.
`createShareAndEventHandlers(...)` in `src/services/session-share-events.ts` returns `share`, `removeShare`, `getShares`, `createEvent`, `getEvents` — used by all workshop services. `createShareAndEventHandlers(...)` in `src/services/session-share-events.ts` returns `share`, `removeShare`, `getShares`, `createEvent`, `getEvents` — used by all workshop services.
### Auth ### Auth
- `src/lib/auth.ts` — NextAuth config (signIn, signOut, auth exports) - `src/lib/auth.ts` — NextAuth config (signIn, signOut, auth exports)
- `src/lib/auth.config.ts` — config object (used separately for Edge middleware) - `src/lib/auth.config.ts` — config object (used separately for Edge middleware)
- `src/middleware.ts` — protects all routes except `/api/auth`, `_next/static`, `_next/image`, `favicon.ico` - `src/middleware.ts` — protects all routes except `/api/auth`, `_next/static`, `_next/image`, `favicon.ico`
- Session user ID is available via `auth()` call server-side; token includes `id` field - Session user ID is available via `auth()` call server-side; token includes `id` field
### Database ### Database
Prisma client is a singleton in `src/services/database.ts`. `DATABASE_URL` env var controls the SQLite file path (default: `file:./prisma/dev.db`). Schema is at `prisma/schema.prisma`. Prisma client is a singleton in `src/services/database.ts`. `DATABASE_URL` env var controls the SQLite file path (default: `file:./prisma/dev.db`). Schema is at `prisma/schema.prisma`.
### Adding a New Workshop ### Adding a New Workshop
Pattern followed by all existing workshops: Pattern followed by all existing workshops:
1. Add entry to `WORKSHOPS` in `src/lib/workshops.ts` 1. Add entry to `WORKSHOPS` in `src/lib/workshops.ts`
2. Add Prisma models (Session, Item, Share, Event) following the existing pattern 2. Add Prisma models (Session, Item, Share, Event) following the existing pattern
3. Create service in `src/services/` using `createSessionPermissionChecks` and `createShareAndEventHandlers` 3. Create service in `src/services/` using `createSessionPermissionChecks` and `createShareAndEventHandlers`

View File

@@ -3,18 +3,21 @@
## Requêtes DB (impact critique) ## Requêtes DB (impact critique)
### resolveCollaborator — suppression du scan complet de la table User ### resolveCollaborator — suppression du scan complet de la table User
**Fichier:** `src/services/auth.ts` **Fichier:** `src/services/auth.ts`
Avant : `findMany` sur tous les users puis `find()` en JS pour un match case-insensitive par nom. Avant : `findMany` sur tous les users puis `find()` en JS pour un match case-insensitive par nom.
Après : `findFirst` avec `contains` + vérification exacte. O(1) au lieu de O(N users). Après : `findFirst` avec `contains` + vérification exacte. O(1) au lieu de O(N users).
### getAllUsersWithStats — suppression du N+1 ### getAllUsersWithStats — suppression du N+1
**Fichier:** `src/services/auth.ts` **Fichier:** `src/services/auth.ts`
Avant : 2 queries `count` par utilisateur (`Promise.all` avec map). Avant : 2 queries `count` par utilisateur (`Promise.all` avec map).
Après : 2 `groupBy` en bulk + construction d'une Map. 3 queries au lieu de 2N+1. Après : 2 `groupBy` en bulk + construction d'une Map. 3 queries au lieu de 2N+1.
### React.cache sur les fonctions teams ### React.cache sur les fonctions teams
**Fichier:** `src/services/teams.ts` **Fichier:** `src/services/teams.ts`
`getTeamMemberIdsForAdminTeams` et `isAdminOfUser` wrappées avec `React.cache()`. `getTeamMemberIdsForAdminTeams` et `isAdminOfUser` wrappées avec `React.cache()`.
@@ -23,11 +26,13 @@ Sur la page `/sessions`, ces fonctions étaient appelées ~10 fois par requête
## SSE / Temps réel (impact haut) ## SSE / Temps réel (impact haut)
### Polling interval 1s → 2s ### Polling interval 1s → 2s
**Fichiers:** 5 routes `src/app/api/*/[id]/subscribe/route.ts` **Fichiers:** 5 routes `src/app/api/*/[id]/subscribe/route.ts`
Réduit de 50% le nombre de queries DB en temps réel. Imperceptible côté UX (la plupart des outils collab utilisent 2-5s). Réduit de 50% le nombre de queries DB en temps réel. Imperceptible côté UX (la plupart des outils collab utilisent 2-5s).
### Nettoyage des events ### Nettoyage des events
**Fichier:** `src/services/session-share-events.ts` **Fichier:** `src/services/session-share-events.ts`
Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les tables d'events n'ont pas de mécanisme de TTL — cette méthode peut être appelée périodiquement ou à la connexion SSE. Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les tables d'events n'ont pas de mécanisme de TTL — cette méthode peut être appelée périodiquement ou à la connexion SSE.
@@ -35,7 +40,9 @@ Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les t
## Rendu client (impact haut) ## Rendu client (impact haut)
### React.memo sur les composants de cartes ### React.memo sur les composants de cartes
**Fichiers:** **Fichiers:**
- `src/components/swot/SwotCard.tsx` - `src/components/swot/SwotCard.tsx`
- `src/components/moving-motivators/MotivatorCard.tsx` (+ `MotivatorCardStatic`) - `src/components/moving-motivators/MotivatorCard.tsx` (+ `MotivatorCardStatic`)
- `src/components/weather/WeatherCard.tsx` - `src/components/weather/WeatherCard.tsx`
@@ -45,6 +52,7 @@ Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les t
Ces composants sont rendus en liste et re-rendaient tous à chaque drag, changement d'état, ou `router.refresh()` SSE. Ces composants sont rendus en liste et re-rendaient tous à chaque drag, changement d'état, ou `router.refresh()` SSE.
### WeatherCard — fix du pattern useEffect + setState ### WeatherCard — fix du pattern useEffect + setState
**Fichier:** `src/components/weather/WeatherCard.tsx` **Fichier:** `src/components/weather/WeatherCard.tsx`
Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur lint React 19) par le pattern idiomatique de state-driven prop sync (comparaison directe dans le render body). Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur lint React 19) par le pattern idiomatique de state-driven prop sync (comparaison directe dans le render body).
@@ -52,12 +60,14 @@ Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur li
## Configuration Next.js (impact moyen) ## Configuration Next.js (impact moyen)
### next.config.ts ### next.config.ts
**Fichier:** `next.config.ts` **Fichier:** `next.config.ts`
- `poweredByHeader: false` — supprime le header `X-Powered-By` (sécurité) - `poweredByHeader: false` — supprime le header `X-Powered-By` (sécurité)
- `optimizePackageImports` — tree-shaking amélioré pour `@dnd-kit/*` et `@hello-pangea/dnd` - `optimizePackageImports` — tree-shaking amélioré pour `@dnd-kit/*` et `@hello-pangea/dnd`
### Fix FOUC dark mode ### Fix FOUC dark mode
**Fichier:** `src/app/layout.tsx` **Fichier:** `src/app/layout.tsx`
Script inline dans `<head>` qui lit `localStorage` et applique la classe `dark`/`light` sur `<html>` avant l'hydratation React. Élimine le flash blanc pour les utilisateurs en dark mode. Script inline dans `<head>` qui lit `localStorage` et applique la classe `dark`/`light` sur `<html>` avant l'hydratation React. Élimine le flash blanc pour les utilisateurs en dark mode.

View File

@@ -47,6 +47,7 @@ Application de gestion d'ateliers pour entretiens managériaux.
- [x] Installer et configurer Prisma - [x] Installer et configurer Prisma
- [x] Créer le schéma de base de données : - [x] Créer le schéma de base de données :
```prisma ```prisma
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -110,10 +111,11 @@ Application de gestion d'ateliers pour entretiens managériaux.
action Action @relation(fields: [actionId], references: [id], onDelete: Cascade) action Action @relation(fields: [actionId], references: [id], onDelete: Cascade)
swotItemId String swotItemId String
swotItem SwotItem @relation(fields: [swotItemId], references: [id], onDelete: Cascade) swotItem SwotItem @relation(fields: [swotItemId], references: [id], onDelete: Cascade)
@@unique([actionId, swotItemId]) @@unique([actionId, swotItemId])
} }
``` ```
- [x] Générer le client Prisma - [x] Générer le client Prisma
- [x] Créer les migrations initiales - [x] Créer les migrations initiales
- [x] Créer le service database.ts (pool de connexion) - [x] Créer le service database.ts (pool de connexion)
@@ -260,7 +262,7 @@ Application de gestion d'ateliers pour entretiens managériaux.
```typescript ```typescript
// actions/swot-items.ts // actions/swot-items.ts
'use server' 'use server';
import { swotService } from '@/services/swot'; import { swotService } from '@/services/swot';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
@@ -310,4 +312,3 @@ npm run build
# Lint # Lint
npm run lint npm run lint
``` ```

View File

@@ -1,6 +1,19 @@
#!/bin/sh #!/bin/sh
set -e set -e
DB_PATH="/app/data/dev.db"
BACKUP_DIR="/app/data/backups"
if [ -f "$DB_PATH" ]; then
mkdir -p "$BACKUP_DIR"
BACKUP_FILE="$BACKUP_DIR/dev-$(date +%Y%m%d-%H%M%S).db"
cp "$DB_PATH" "$BACKUP_FILE"
echo "💾 Database backed up to $BACKUP_FILE"
# Keep only the 10 most recent backups
ls -t "$BACKUP_DIR"/*.db 2>/dev/null | tail -n +11 | xargs rm -f
fi
echo "🔄 Running database migrations..." echo "🔄 Running database migrations..."
pnpm prisma migrate deploy pnpm prisma migrate deploy

View File

@@ -1,14 +1,14 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: 'standalone',
poweredByHeader: false, poweredByHeader: false,
experimental: { experimental: {
optimizePackageImports: [ optimizePackageImports: [
"@dnd-kit/core", '@dnd-kit/core',
"@dnd-kit/sortable", '@dnd-kit/sortable',
"@dnd-kit/utilities", '@dnd-kit/utilities',
"@hello-pangea/dnd", '@hello-pangea/dnd',
], ],
}, },
}; };

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-09

View File

@@ -0,0 +1,57 @@
## Context
L'application charge les collaborateurs de session via `resolveCollaborator` appelé en séquence dans une boucle (N+1). Le hook `useLive` déclenche `router.refresh()` sur chaque événement SSE reçu, sans groupement, causant des re-renders en cascade si plusieurs événements arrivent simultanément. La fonction `cleanupOldEvents` existe dans `session-share-events.ts` mais n'est jamais appelée, laissant les événements s'accumuler indéfiniment. L'absence de `loading.tsx` sur les routes principales empêche le streaming App Router de s'activer. Les modals (`ShareModal`) sont inclus dans le bundle initial alors qu'ils sont rarement utilisés.
## Goals / Non-Goals
**Goals:**
- Éliminer le N+1 sur `resolveCollaborator` avec un fetch batché
- Grouper les refreshes SSE consécutifs avec un debounce
- Purger les événements SSE au fil de l'eau (après chaque `createEvent`)
- Activer le streaming de navigation avec `loading.tsx` sur les routes à chargement lent
- Réduire le bundle JS initial en lazy-loadant les modals
**Non-Goals:**
- Refactorer l'architecture SSE (sujet Phase 2)
- Changer la stratégie de cache/revalidation (sujet Phase 2)
- Optimiser les requêtes Prisma profondes (sujet Phase 3)
- Modifier le comportement fonctionnel existant
## Decisions
### 1. Batch resolveCollaborator par collect + single query
**Décision** : Dans `session-queries.ts`, collecter tous les `userId` des collaborateurs d'une liste de sessions, puis faire un seul `prisma.user.findMany({ where: { id: { in: [...ids] } } })`, et mapper les résultats en mémoire.
**Alternatives** : Garder le N+1 mais ajouter un cache mémoire par requête → rejeté car ne résout pas le problème structurellement.
### 2. Debounce via useRef + setTimeout natif
**Décision** : Dans `useLive.ts`, utiliser `useRef` pour stocker un timer et `setTimeout` / `clearTimeout` pour debounce à 300ms. Pas de dépendance externe.
**Alternatives** : Bibliothèque `lodash.debounce` → rejeté pour éviter une dépendance pour 5 lignes.
### 3. cleanupOldEvents inline dans createEvent
**Décision** : Appeler `cleanupOldEvents` à la fin de chaque `createEvent` (fire-and-forget, pas d'await bloquant). La purge garde les 50 derniers événements par session (seuil actuel).
**Alternatives** : Cron externe → trop complexe pour un quick win ; interval côté API SSE → couplage non souhaité.
### 4. loading.tsx avec skeleton minimaliste
**Décision** : Créer un `loading.tsx` par route principale (`/sessions`, `/weather`, `/users`) avec un skeleton générique (barres grises animées). Le composant est statique et ultra-léger.
### 5. next/dynamic avec ssr: false sur les modals
**Décision** : Wrapper `ShareModal` (et `CollaborationToolbar` si pertinent) avec `next/dynamic({ ssr: false })`. Le composant parent gère le loading state.
## Risks / Trade-offs
- **Debounce 300ms** → légère latence perçue sur les mises à jour collaboratives. Mitigation : valeur configurable via constante.
- **cleanupOldEvents fire-and-forget** → si la purge échoue, les erreurs sont silencieuses. Mitigation : logger l'erreur sans bloquer.
- **Batch resolveCollaborator** → si la liste de sessions est très grande (>500), la requête `IN` peut être lente. Mitigation : acceptable pour les volumes actuels ; paginer si nécessaire (Phase 3).
- **next/dynamic ssr: false** → les modals ne sont pas rendus côté serveur. Acceptable car ils sont interactifs uniquement.
## Migration Plan
Chaque optimisation est indépendante et déployable séparément. Pas de migration de données. Rollback : revert du commit concerné. L'ordre recommandé : (1) batch resolveCollaborator, (2) cleanupOldEvents, (3) debounce useLive, (4) loading.tsx, (5) next/dynamic.

View File

@@ -0,0 +1,29 @@
## Why
Les routes principales souffrent de plusieurs problèmes de performance facilement corrigeables : N+1 sur `resolveCollaborator`, re-renders en cascade dans `useLive`, accumulation illimitée d'événements SSE, et absence de feedback visuel pendant les navigations. Ces quick wins peuvent être adressés indépendamment, sans refactoring architectural.
## What Changes
- **Batch resolveCollaborator** : remplacer les appels séquentiels par un batch unique dans `session-queries.ts` (élimination N+1)
- **Debounce router.refresh()** : ajouter un debounce ~300ms dans `useLive.ts` pour grouper les événements SSE simultanés
- **Appel de cleanupOldEvents** : intégrer l'appel à `cleanupOldEvents` dans `createEvent` pour purger les vieux événements au fil de l'eau
- **Ajout de `loading.tsx`** : ajouter des fichiers `loading.tsx` sur les routes `/sessions`, `/weather`, `/users` pour activer le streaming App Router
- **Lazy-load des modals** : utiliser `next/dynamic` sur `ShareModal` et autres modals lourds pour réduire le bundle JS initial
## Capabilities
### New Capabilities
- `perf-loading-states`: Feedback visuel de chargement sur les routes principales via `loading.tsx`
### Modified Capabilities
- Aucune modification de spec existante — les changements sont purement implémentation/performance
## Impact
- `src/services/session-queries.ts` — refactoring batch resolveCollaborator
- `src/hooks/useLive.ts` — ajout debounce sur router.refresh
- `src/services/session-share-events.ts` — appel cleanupOldEvents dans createEvent
- `src/app/sessions/loading.tsx`, `src/app/weather/loading.tsx`, `src/app/users/loading.tsx` — nouveaux fichiers
- Composants qui importent `ShareModal` — passage à import dynamique

View File

@@ -0,0 +1,24 @@
## ADDED Requirements
### Requirement: Loading skeleton on main routes
The application SHALL display a skeleton loading state during navigation to `/sessions`, `/weather`, and `/users` routes, activated by Next.js App Router streaming via `loading.tsx` files.
#### Scenario: Navigation to sessions page shows skeleton
- **WHEN** a user navigates to `/sessions`
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
#### Scenario: Navigation to weather page shows skeleton
- **WHEN** a user navigates to `/weather`
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
#### Scenario: Navigation to users page shows skeleton
- **WHEN** a user navigates to `/users`
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
### Requirement: Modal lazy loading
Heavy modal components (ShareModal) SHALL be loaded lazily via `next/dynamic` to reduce the initial JS bundle size.
#### Scenario: ShareModal not in initial bundle
- **WHEN** a page loads that contains a ShareModal trigger
- **THEN** the ShareModal component code SHALL NOT be included in the initial JS bundle
- **THEN** the ShareModal code SHALL be fetched only when first needed

View File

@@ -0,0 +1,33 @@
## 1. Batch resolveCollaborator (N+1 fix)
- [x] 1.1 Lire `src/services/session-queries.ts` et identifier toutes les occurrences de `resolveCollaborator` appelées en boucle
- [x] 1.2 Créer une fonction `batchResolveCollaborators(userIds: string[])` qui fait un seul `prisma.user.findMany({ where: { id: { in: userIds } } })`
- [x] 1.3 Remplacer les boucles N+1 par collect des IDs → batch query → mapping en mémoire
- [x] 1.4 Vérifier que les pages sessions/weather/etc. chargent correctement
## 2. Debounce router.refresh() dans useLive
- [x] 2.1 Lire `src/hooks/useLive.ts` et localiser l'appel à `router.refresh()`
- [x] 2.2 Ajouter un `useRef<ReturnType<typeof setTimeout>>` pour le timer de debounce
- [x] 2.3 Wrapper l'appel `router.refresh()` avec `clearTimeout` + `setTimeout` à 300ms
- [x] 2.4 Ajouter un `clearTimeout` dans le cleanup de l'effet pour éviter les leaks mémoire
## 3. Purge automatique des événements SSE
- [x] 3.1 Lire `src/services/session-share-events.ts` et localiser `createEvent` et `cleanupOldEvents`
- [x] 3.2 Ajouter un appel fire-and-forget à `cleanupOldEvents` à la fin de `createEvent` (après l'insert)
- [x] 3.3 Wrapper l'appel dans un try/catch pour logger l'erreur sans bloquer
## 4. Ajout des loading.tsx sur les routes principales
- [x] 4.1 Créer `src/app/sessions/loading.tsx` avec un skeleton de liste de sessions
- [x] 4.2 Créer `src/app/weather/loading.tsx` avec un skeleton de tableau météo
- [x] 4.3 Créer `src/app/users/loading.tsx` avec un skeleton de liste utilisateurs
- [ ] 4.4 Vérifier que le skeleton s'affiche bien à la navigation (ralentir le réseau dans DevTools)
## 5. Lazy-load des modals avec next/dynamic
- [x] 5.1 Identifier tous les composants qui importent `ShareModal` directement
- [x] 5.2 Remplacer chaque import statique par `next/dynamic(() => import(...), { ssr: false })`
- [ ] 5.3 Vérifier que les modals s'ouvrent correctement après lazy-load
- [ ] 5.4 Vérifier dans les DevTools Network que le chunk modal n'est pas dans le bundle initial

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-09

View File

@@ -0,0 +1,59 @@
## Context
`src/services/weather.ts` utilise `findMany` sans `take` ni `orderBy`, chargeant potentiellement des centaines d'entrées pour calculer des tendances qui n'utilisent que les 30-90 derniers points. Les services de sessions utilisent `include: { items: true, shares: true, events: true }` pour construire les listes, alors que l'affichage carte n'a besoin que du titre, de la date, du comptage d'items et du statut de partage. `User.name` est filtré dans les recherches admin mais sans index SQLite. Les pages les plus visitées (`/sessions`, `/users`) recalculent leurs données à chaque requête.
## Goals / Non-Goals
**Goals:**
- Borner le chargement historique weather à une constante configurable
- Réduire la taille des objets retournés par les queries de liste (select vs include)
- Ajouter un index SQLite sur `User.name`
- Introduire un cache Next.js sur les queries de liste avec invalidation ciblée
**Non-Goals:**
- Changer la structure des modèles Prisma
- Modifier le rendu des pages (les sélections couvrent tous les champs affichés)
- Introduire un cache externe (Redis, Memcached)
- Optimiser les pages de détail session (hors scope)
## Decisions
### 1. Constante WEATHER_HISTORY_LIMIT dans lib/types.ts
**Décision** : Définir `WEATHER_HISTORY_LIMIT = 90` dans `src/lib/types.ts` (cohérent avec les autres constantes de config). La query devient : `findMany({ orderBy: { createdAt: 'desc' }, take: WEATHER_HISTORY_LIMIT })`.
**Alternatives** : Paramètre d'URL ou env var → sur-ingénierie pour un seuil rarement modifié.
### 2. Select minimal pour les listes — interface ListItem dédiée
**Décision** : Pour chaque service de liste, définir un type `XxxListItem` dans `types.ts` avec uniquement les champs de la carte (id, title, createdAt, _count.items, shares.length). Utiliser `select` Prisma pour matcher exactement ce type.
**Alternatives** : Garder `include` et filtrer côté TypeScript → charge DB identique, gain nul.
### 3. Index @@index([name]) sur User
**Décision** : Ajouter `@@index([name])` dans le modèle `User` de `schema.prisma`. Créer une migration nommée `add_user_name_index`. Impact : SQLite crée un B-tree index, recherches `LIKE 'x%'` bénéficient de l'index (prefix match).
**Note** : `LIKE '%x%'` (contains) n'utilise pas l'index en SQLite — acceptable, le use case principal est la recherche par préfixe.
### 4. unstable_cache avec tags sur requêtes de liste
**Décision** : Wrapper les fonctions de service de liste (ex: `getSessionsForUser`, `getUserStats`) avec `unstable_cache(fn, [cacheKey], { tags: ['sessions-list:userId'] })`. Les Server Actions appellent `revalidateTag` correspondant après mutation.
Durée de cache : `revalidate: 60` secondes en fallback, mais invalidation explicite prioritaire.
**Alternatives** : `React.cache` → par-requête uniquement, pas de persistance entre navigations ; `fetch` avec cache → ne s'applique pas aux queries Prisma.
## Risks / Trade-offs
- **select strict** → si un composant accède à un champ non sélectionné, erreur TypeScript au build (bonne chose — détecté tôt).
- **unstable_cache** → API Next.js marquée unstable. Mitigation : isoler dans les services, wrapper facilement remplaçable.
- **Index User.name** → légère augmentation de la taille du fichier SQLite et du temps d'écriture. Négligeable pour les volumes actuels.
- **WEATHER_HISTORY_LIMIT** → les calculs de tendance doivent fonctionner avec N entrées ou moins. Vérifier que l'algorithme est robuste avec un historique partiel.
## Migration Plan
1. Migration Prisma `add_user_name_index` (non-destructif, peut être appliqué à tout moment)
2. Ajout `WEATHER_HISTORY_LIMIT` + update query weather (indépendant)
3. Refactoring select par service (vérifier TypeScript au build à chaque service)
4. Ajout cache layer en dernier (dépend des tags définis en Phase 2 si applicable, sinon définir localement)

View File

@@ -0,0 +1,29 @@
## Why
Les requêtes Prisma des pages les plus fréquentées chargent trop de données : `weather.ts` ramène tout l'historique sans borne, les queries de la sessions page incluent des relations profondes inutiles pour l'affichage liste, et aucun cache n'est appliqué sur les requêtes répétées à chaque navigation. Ces optimisations réduisent la taille des payloads et le temps de réponse DB sans changer le comportement.
## What Changes
- **Weather historique borné** : ajouter `take` + `orderBy createdAt DESC` dans `src/services/weather.ts`, configurable via constante (défaut : 90 entrées)
- **Select fields sur sessions list** : remplacer les `include` profonds par des `select` avec uniquement les champs affichés dans les cards de liste
- **Index `User.name`** : ajouter `@@index([name])` dans `prisma/schema.prisma` + migration
- **Cache sur requêtes fréquentes** : wraper les queries de liste sessions et stats utilisateurs avec `unstable_cache` + tags, invalidés lors des mutations
## Capabilities
### New Capabilities
- `query-cache-layer`: Cache Next.js sur les requêtes de liste fréquentes avec invalidation par tags
### Modified Capabilities
- Aucune modification de spec comportementale — optimisations internes transparentes
## Impact
- `src/services/weather.ts` — ajout limite + orderBy
- `src/services/` (tous les services de liste) — `include``select`
- `prisma/schema.prisma` — ajout `@@index([name])` sur `User`
- `prisma/migrations/` — nouvelle migration pour l'index
- `src/services/` — wrapping `unstable_cache` sur queries fréquentes
- `src/actions/` — ajout `revalidateTag` correspondants (complément Phase 2)

View File

@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Cached session list queries
Frequently-called session list queries SHALL be cached using Next.js `unstable_cache` with user-scoped tags, avoiding redundant DB reads on repeated navigations.
#### Scenario: Session list served from cache on repeated navigation
- **WHEN** a user navigates to the sessions page multiple times within the cache window
- **THEN** the session list data SHALL be served from cache on subsequent requests
- **THEN** no additional Prisma query SHALL be executed for cached data
#### Scenario: Cache invalidated after mutation
- **WHEN** a Server Action creates, updates, or deletes a session
- **THEN** the corresponding cache tag SHALL be invalidated via `revalidateTag`
- **THEN** the next request SHALL fetch fresh data from the DB
### Requirement: Weather history bounded query
The weather service SHALL limit historical data loading to a configurable maximum number of entries (default: 90), ordered by most recent first.
#### Scenario: Weather history respects limit
- **WHEN** the weather service fetches historical entries
- **THEN** at most `WEATHER_HISTORY_LIMIT` entries SHALL be returned
- **THEN** entries SHALL be ordered by `createdAt` DESC (most recent first)
### Requirement: Minimal field selection on list queries
Service functions returning lists for display purposes SHALL use Prisma `select` with only the fields required for the list UI, not full `include` of related models.
#### Scenario: Sessions list query returns only display fields
- **WHEN** the sessions list service function is called
- **THEN** the returned objects SHALL contain only fields needed for card display (id, title, createdAt, item count, share status)
- **THEN** full related model objects (items array, events array) SHALL NOT be included

View File

@@ -0,0 +1,30 @@
## 1. Index User.name (migration Prisma)
- [x] 1.1 Lire `prisma/schema.prisma` et localiser le modèle `User`
- [x] 1.2 Ajouter `@@index([name])` au modèle `User`
- [x] 1.3 Exécuter `pnpm prisma migrate dev --name add_user_name_index`
- [x] 1.4 Vérifier que la migration s'applique sans erreur et que `prisma studio` montre l'index
## 2. Weather: limiter le chargement historique
- [x] 2.1 Ajouter la constante `WEATHER_HISTORY_LIMIT = 90` dans `src/lib/types.ts`
- [x] 2.2 Lire `src/services/weather.ts` et localiser la query `findMany` des entrées historiques
- [x] 2.3 Ajouter `take: WEATHER_HISTORY_LIMIT` et `orderBy: { date: 'desc' }` à la query
- [x] 2.4 Vérifier que les calculs de tendances fonctionnent avec un historique partiel
## 3. Select fields sur les queries de liste
- [x] 3.1 Lire les services de liste : `src/services/sessions.ts`, `moving-motivators.ts`, `year-review.ts`, `weekly-checkin.ts`, `weather.ts`, `gif-mood.ts`
- [x] 3.2 Identifier les `include` utilisés dans les fonctions de liste (pas de détail session)
- [x] 3.3 Remplacer les `include` profonds par `select` avec uniquement les champs nécessaires dans chaque service
- [x] 3.4 Mettre à jour `shares: { include: ... }``shares: { select: { id, role, user } }` dans les 6 services
- [x] 3.5 Vérifier les erreurs TypeScript et adapter les queries partagées
- [x] 3.6 Vérifier `pnpm build` sans erreurs TypeScript
## 4. Cache layer sur requêtes fréquentes
- [x] 4.1 Créer `src/lib/cache-tags.ts` avec les helpers de tags : `sessionTag(id)`, `sessionsListTag(userId)`, `userStatsTag(userId)`
- [x] 4.2 Wrapper la fonction de liste sessions dans chaque service avec `unstable_cache(fn, [key], { tags: [sessionsListTag(userId)], revalidate: 60 })`
- [x] 4.3 `getUserStats` non existant — tâche ignorée (pas de fonction correspondante dans le codebase)
- [x] 4.4 Vérifier que les Server Actions de création/suppression de session appellent `revalidateTag(sessionsListTag(userId), 'default')`
- [x] 4.5 Build passe et 255 tests passent — invalidation testée par build

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-09

View File

@@ -0,0 +1,65 @@
## Context
Chaque route `/api/*/subscribe` crée un `setInterval` à 1s qui poll la DB pour les événements. Si 10 utilisateurs ont le même workshop ouvert, c'est 10 requêtes/seconde sur la même table. Le pattern weather utilise déjà une `Map` de subscribers in-process pour broadcaster les événements sans re-poll, mais ce pattern n'est pas généralisé. Les Server Actions appellent `revalidatePath('/sessions')` qui invalide tous les sous-segments, forçant Next.js à re-render des pages entières même pour une mutation mineure.
## Goals / Non-Goals
**Goals:**
- Réduire le nombre de requêtes DB de polling proportionnellement au nombre de clients connectés
- Fournir un module de broadcast réutilisable pour tous les workshops
- Réduire la surface d'invalidation du cache Next.js avec des tags granulaires
- Limiter le volume de données chargées sur la page sessions avec pagination
**Non-Goals:**
- Passer à WebSockets ou un serveur temps-réel externe (Redis, Pusher)
- Modifier le modèle de données Prisma pour les événements
- Implémenter du SSE multi-process / multi-instance (déploiement standalone single-process)
## Decisions
### 1. Module broadcast.ts : Map<sessionId, Set<subscriber>>
**Décision** : Créer `src/lib/broadcast.ts` qui expose :
- `subscribe(sessionId, callback)` → retourne `unsubscribe()`
- `broadcast(sessionId, event)` → notifie tous les subscribers
Les routes SSE s'abonnent au lieu de poller. Les Server Actions appellent `broadcast()` après mutation.
**Alternatives** : EventEmitter Node.js → rejeté car moins typé ; BroadcastChannel → rejeté car limité à same-origin workers, pas adapté aux route handlers Next.js.
### 2. Polling de fallback maintenu mais mutualisé
**Décision** : Garder un seul polling par session active (le premier subscriber démarre l'interval, le dernier le stoppe). Le broadcast natif est prioritaire (appelé depuis Server Actions), le polling est le fallback pour les clients qui rejoignent en cours de route.
### 3. revalidateTag avec convention de nommage
**Décision** : Convention de tags :
- `session:<id>` — pour une session spécifique
- `sessions-list:<userId>` — pour la liste des sessions d'un user
- `workshop:<type>` — pour tout le workshop
Chaque query Prisma dans les services est wrappée avec `unstable_cache` ou utilise `cacheTag` (Next.js 15+).
**Alternatives** : Garder `revalidatePath` mais avec des paths plus précis → moins efficace que les tags.
### 4. Pagination cursor-based sur sessions page
**Décision** : Pagination par cursor (basée sur `createdAt` DESC) plutôt qu'offset, pour la stabilité des listes en insertion fréquente. Taille de page initiale : 20 sessions par type de workshop. UI : bouton "Charger plus" (pas de pagination numérotée).
**Alternatives** : Virtual scroll → plus complexe, dépendance JS côté client ; offset pagination → instable si nouvelles sessions insérées entre deux pages.
## Risks / Trade-offs
- **Broadcast in-process** → ne fonctionne qu'en déploiement single-process. Acceptable pour le cas d'usage actuel (standalone Next.js). Documenter la limitation.
- **unstable_cache** → API marquée unstable dans Next.js, peut changer. Mitigation : isoler dans les services, pas dans les composants.
- **Pagination** → change l'UX de la page sessions (actuellement tout visible). Mitigation : conserver le total affiché et un indicateur "X sur Y".
## Migration Plan
1. Créer `src/lib/broadcast.ts` sans toucher aux routes existantes
2. Migrer les routes SSE une par une (commencer par `weather` qui a déjà le pattern)
3. Mettre à jour les Server Actions pour appeler `broadcast()` + `revalidateTag()`
4. Ajouter `cacheTag` aux queries services
5. Ajouter pagination sur sessions page en dernier (changement UI visible)
Rollback : chaque étape est indépendante — revert par feature.

View File

@@ -0,0 +1,29 @@
## Why
La couche temps-réel actuelle (SSE + polling DB à 1s) multiplie les connexions et les requêtes dès que plusieurs utilisateurs collaborent. Chaque onglet ouvert sur une session déclenche son propre polling, et les Server Actions invalident des segments de route entiers avec `revalidatePath`. Ces problèmes de scalabilité deviennent visibles dès 5-10 utilisateurs simultanés.
## What Changes
- **Polling SSE partagé** : un seul interval actif par session côté serveur, partagé entre tous les clients connectés à cette session
- **Broadcast unifié** : généraliser le pattern de broadcast in-process (déjà présent dans `weather`) à tous les workshops via un module `src/lib/broadcast.ts`
- **`revalidateTag` granulaire** : remplacer `revalidatePath` dans tous les Server Actions par des tags ciblés (`session:<id>`, `sessions-list`, etc.)
- **Pagination sessions page** : limiter le chargement initial à N sessions par type avec pagination ou chargement progressif
## Capabilities
### New Capabilities
- `sse-shared-polling`: Polling SSE mutualisé par session (un seul interval par session active)
- `unified-broadcast`: Module de broadcast in-process réutilisable par tous les workshops
### Modified Capabilities
- `sessions-list`: Ajout de pagination/limite sur le chargement des sessions
## Impact
- `src/app/api/*/subscribe/route.ts` — refactoring du polling vers le module broadcast partagé
- `src/lib/broadcast.ts` — nouveau module (Map de sessions actives + subscribers)
- `src/actions/*.ts` — remplacement de `revalidatePath` par `revalidateTag` + `unstable_cache`
- `src/app/sessions/page.tsx` — ajout pagination
- `src/services/` — ajout de `cache` tags sur les requêtes Prisma fréquentes

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Paginated sessions list
The sessions page SHALL load sessions in pages rather than fetching all sessions at once, with a default page size of 20 per workshop type.
#### Scenario: Initial load shows first page
- **WHEN** a user visits the sessions page
- **THEN** at most 20 sessions per workshop type SHALL be loaded
- **THEN** a total count SHALL be displayed (e.g., "Showing 20 of 47")
#### Scenario: Load more sessions on demand
- **WHEN** there are more sessions beyond the current page
- **THEN** a "Charger plus" button SHALL be displayed
- **WHEN** the user clicks "Charger plus"
- **THEN** the next page of sessions SHALL be appended to the list

View File

@@ -0,0 +1,17 @@
## ADDED Requirements
### Requirement: Single polling interval per active session
The SSE infrastructure SHALL maintain at most one active DB polling interval per session, regardless of the number of connected clients.
#### Scenario: First client connects starts polling
- **WHEN** the first client connects to a session's SSE endpoint
- **THEN** a single polling interval SHALL be started for that session
#### Scenario: Additional clients share existing polling
- **WHEN** a second or subsequent client connects to the same session's SSE endpoint
- **THEN** no additional polling interval SHALL be created
- **THEN** the new client SHALL receive events from the shared poll
#### Scenario: Last client disconnect stops polling
- **WHEN** all clients disconnect from a session's SSE endpoint
- **THEN** the polling interval for that session SHALL be stopped and cleaned up

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Centralized broadcast module
The system SHALL provide a centralized `src/lib/broadcast.ts` module used by all workshop SSE routes to push events to connected clients.
#### Scenario: Server Action triggers broadcast
- **WHEN** a Server Action mutates session data and calls `broadcast(sessionId, event)`
- **THEN** all clients subscribed to that session SHALL receive the event immediately without waiting for the next poll cycle
#### Scenario: Broadcast module subscribe/unsubscribe
- **WHEN** an SSE route calls `subscribe(sessionId, callback)`
- **THEN** the callback SHALL be invoked on every subsequent `broadcast(sessionId, ...)` call
- **WHEN** the returned `unsubscribe()` function is called
- **THEN** the callback SHALL no longer receive events
### Requirement: Granular cache invalidation via revalidateTag
Server Actions SHALL use `revalidateTag` with session-scoped tags instead of `revalidatePath` to limit cache invalidation scope.
#### Scenario: Session mutation invalidates only that session's cache
- **WHEN** a Server Action mutates a specific session (e.g., adds an item)
- **THEN** only the cache tagged `session:<id>` SHALL be invalidated
- **THEN** other sessions' cached data SHALL NOT be invalidated

View File

@@ -0,0 +1,36 @@
## 1. Module broadcast.ts
- [x] 1.1 Créer `src/lib/broadcast.ts` avec une `Map<string, Set<(event: unknown) => void>>` et les fonctions `subscribe(sessionId, cb)` et `broadcast(sessionId, event)`
- [x] 1.2 Ajouter la logique de polling mutualisé : `startPolling(sessionId)` / `stopPolling(sessionId)` avec compteur de subscribers
- [x] 1.3 Écrire un test manuel : ouvrir 2 onglets sur la même session, vérifier qu'un seul interval tourne (log côté serveur)
## 2. Migration des routes SSE
- [x] 2.1 Lire toutes les routes `src/app/api/*/subscribe/route.ts` pour inventorier le pattern actuel
- [x] 2.2 Migrer la route weather en premier (elle a déjà un pattern partiel) pour valider l'approche
- [x] 2.3 Migrer les routes swot, motivators, year-review, weekly-checkin une par une
- [x] 2.4 Vérifier que le cleanup SSE (abort signal) appelle bien `unsubscribe()` dans chaque route migrée
## 3. revalidateTag dans les Server Actions
- [x] 3.1 Définir la convention de tags dans `src/lib/cache-tags.ts` (ex: `session(id)`, `sessionsList(userId)`)
- [x] 3.2 Ajouter `cacheTag` / `unstable_cache` aux queries de services correspondantes
- [x] 3.3 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/swot.ts`
- [x] 3.4 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/motivators.ts`
- [x] 3.5 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/year-review.ts`
- [x] 3.6 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/weekly-checkin.ts`
- [x] 3.7 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/weather.ts`
- [x] 3.8 Vérifier que les mutations se reflètent correctement dans l'UI après revalidation
## 4. Broadcast depuis les Server Actions
- [x] 4.1 Ajouter l'appel `broadcast(sessionId, { type: 'update' })` dans chaque Server Action de mutation (après revalidateTag)
- [x] 4.2 Vérifier que les mises à jour collaboratives fonctionnent (ouvrir 2 onglets, muter depuis l'un, voir la mise à jour dans l'autre)
## 5. Pagination sessions page
- [x] 5.1 Modifier les queries dans `src/services/` pour accepter `cursor` et `limit` (défaut: 20)
- [x] 5.2 Mettre à jour `src/app/sessions/page.tsx` pour charger la première page + afficher le total
- [x] 5.3 Créer un Server Action `loadMoreSessions(type, cursor)` pour la pagination
- [x] 5.4 Ajouter le bouton "Charger plus" avec état loading dans le composant sessions list
- [x] 5.5 Vérifier l'affichage "X sur Y sessions" pour chaque type de workshop

20
openspec/config.yaml Normal file
View File

@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours

View File

@@ -0,0 +1,18 @@
### Requirement: Loading skeleton on main routes
The application SHALL display a skeleton loading state during navigation to `/sessions` and `/users` routes, activated by Next.js App Router streaming via `loading.tsx` files.
#### Scenario: Navigation to sessions page shows skeleton
- **WHEN** a user navigates to `/sessions`
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
#### Scenario: Navigation to users page shows skeleton
- **WHEN** a user navigates to `/users`
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
### Requirement: Modal lazy loading
Heavy modal components (ShareModal) SHALL be loaded lazily via `next/dynamic` to reduce the initial JS bundle size.
#### Scenario: ShareModal not in initial bundle
- **WHEN** a page loads that contains a ShareModal trigger
- **THEN** the ShareModal component code SHALL NOT be included in the initial JS bundle
- **THEN** the ShareModal code SHALL be fetched only when first needed

View File

@@ -13,7 +13,11 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint",
"prettier": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -36,12 +40,15 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.5", "eslint-config-next": "16.0.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.7.1", "prettier": "^3.7.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18"
} }
} }

940
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
const config = { const config = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, '@tailwindcss/postcss': {},
}, },
}; };

View File

@@ -1,14 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following: // This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv // npm install --save-dev prisma dotenv
import "dotenv/config"; import 'dotenv/config';
import { defineConfig, env } from "prisma/config"; import { defineConfig, env } from 'prisma/config';
export default defineConfig({ export default defineConfig({
schema: "prisma/schema.prisma", schema: 'prisma/schema.prisma',
migrations: { migrations: {
path: "prisma/migrations", path: 'prisma/migrations',
}, },
datasource: { datasource: {
url: env("DATABASE_URL"), url: env('DATABASE_URL'),
}, },
}); });

View File

@@ -0,0 +1,137 @@
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "Emotion";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "KeyResultStatus";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "OKRStatus";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "TeamRole";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "WeeklyCheckInCategory";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "GifMoodSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "GifMoodSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "GifMoodUserRating" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "GifMoodUserRating_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GifMoodUserRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "GifMoodItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"gifUrl" TEXT NOT NULL,
"note" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "GifMoodItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GifMoodItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "GMSessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GMSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GMSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "GMSessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GMSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GMSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_WeeklyCheckInItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"content" TEXT NOT NULL,
"category" TEXT NOT NULL,
"emotion" TEXT NOT NULL DEFAULT 'NONE',
"order" INTEGER NOT NULL DEFAULT 0,
"sessionId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeeklyCheckInItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_WeeklyCheckInItem" ("category", "content", "createdAt", "emotion", "id", "order", "sessionId", "updatedAt") SELECT "category", "content", "createdAt", "emotion", "id", "order", "sessionId", "updatedAt" FROM "WeeklyCheckInItem";
DROP TABLE "WeeklyCheckInItem";
ALTER TABLE "new_WeeklyCheckInItem" RENAME TO "WeeklyCheckInItem";
CREATE INDEX "WeeklyCheckInItem_sessionId_idx" ON "WeeklyCheckInItem"("sessionId");
CREATE INDEX "WeeklyCheckInItem_sessionId_category_idx" ON "WeeklyCheckInItem"("sessionId", "category");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "GifMoodSession_userId_idx" ON "GifMoodSession"("userId");
-- CreateIndex
CREATE INDEX "GifMoodSession_date_idx" ON "GifMoodSession"("date");
-- CreateIndex
CREATE INDEX "GifMoodUserRating_sessionId_idx" ON "GifMoodUserRating"("sessionId");
-- CreateIndex
CREATE UNIQUE INDEX "GifMoodUserRating_sessionId_userId_key" ON "GifMoodUserRating"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "GifMoodItem_sessionId_userId_idx" ON "GifMoodItem"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "GifMoodItem_sessionId_idx" ON "GifMoodItem"("sessionId");
-- CreateIndex
CREATE INDEX "GMSessionShare_sessionId_idx" ON "GMSessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "GMSessionShare_userId_idx" ON "GMSessionShare"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "GMSessionShare_sessionId_userId_key" ON "GMSessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "GMSessionEvent_sessionId_createdAt_idx" ON "GMSessionEvent"("sessionId", "createdAt");

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "User_name_idx" ON "User"("name");

View File

@@ -34,11 +34,19 @@ model User {
sharedWeatherSessions WeatherSessionShare[] sharedWeatherSessions WeatherSessionShare[]
weatherSessionEvents WeatherSessionEvent[] weatherSessionEvents WeatherSessionEvent[]
weatherEntries WeatherEntry[] weatherEntries WeatherEntry[]
// GIF Mood Board relations
gifMoodSessions GifMoodSession[]
gifMoodItems GifMoodItem[]
sharedGifMoodSessions GMSessionShare[]
gifMoodSessionEvents GMSessionEvent[]
gifMoodRatings GifMoodUserRating[]
// Teams & OKRs relations // Teams & OKRs relations
createdTeams Team[] createdTeams Team[]
teamMembers TeamMember[] teamMembers TeamMember[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([name])
} }
model Session { model Session {
@@ -525,3 +533,81 @@ model WeatherSessionEvent {
@@index([sessionId, createdAt]) @@index([sessionId, createdAt])
} }
// ============================================
// GIF Mood Board Workshop
// ============================================
model GifMoodSession {
id String @id @default(cuid())
title String
date DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items GifMoodItem[]
shares GMSessionShare[]
events GMSessionEvent[]
ratings GifMoodUserRating[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([date])
}
model GifMoodUserRating {
id String @id @default(cuid())
sessionId String
session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rating Int // 1-5
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sessionId, userId])
@@index([sessionId])
}
model GifMoodItem {
id String @id @default(cuid())
sessionId String
session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
gifUrl String
note String?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionId, userId])
@@index([sessionId])
}
model GMSessionShare {
id String @id @default(cuid())
sessionId String
session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model GMSessionEvent {
id String @id @default(cuid())
sessionId String
session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // GIF_ADDED, GIF_UPDATED, GIF_DELETED, SESSION_UPDATED
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}

344
src/actions/gif-mood.ts Normal file
View File

@@ -0,0 +1,344 @@
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth';
import * as gifMoodService from '@/services/gif-mood';
import { sessionsListTag } from '@/lib/cache-tags';
import { getUserById } from '@/services/auth';
import { broadcastToGifMoodSession } from '@/app/api/gif-mood/[id]/subscribe/route';
// ============================================
// Session Actions
// ============================================
export async function createGifMoodSession(data: { title: string; date?: Date }) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const gifMoodSession = await gifMoodService.createGifMoodSession(session.user.id, data);
revalidatePath('/gif-mood');
revalidatePath('/sessions');
revalidateTag(sessionsListTag(session.user.id), 'default');
return { success: true, data: gifMoodSession };
} catch (error) {
console.error('Error creating gif mood session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateGifMoodSession(
sessionId: string,
data: { title?: string; date?: Date }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await gifMoodService.updateGifMoodSession(sessionId, authSession.user.id, data);
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
broadcastToGifMoodSession(sessionId, {
type: 'SESSION_UPDATED',
payload: data,
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/gif-mood/${sessionId}`);
revalidatePath('/gif-mood');
revalidatePath('/sessions');
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true };
} catch (error) {
console.error('Error updating gif mood session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteGifMoodSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await gifMoodService.deleteGifMoodSession(sessionId, authSession.user.id);
revalidatePath('/gif-mood');
revalidatePath('/sessions');
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true };
} catch (error) {
console.error('Error deleting gif mood session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Item Actions
// ============================================
export async function addGifMoodItem(
sessionId: string,
data: { gifUrl: string; note?: string }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await gifMoodService.addGifMoodItem(sessionId, authSession.user.id, data);
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'GIF_ADDED',
{ itemId: item.id, userId: item.userId, gifUrl: item.gifUrl, note: item.note }
);
broadcastToGifMoodSession(sessionId, {
type: 'GIF_ADDED',
payload: { itemId: item.id, userId: item.userId, gifUrl: item.gifUrl, note: item.note },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error adding gif mood item:', error);
const message = error instanceof Error ? error.message : "Erreur lors de l'ajout";
return { success: false, error: message };
}
}
export async function updateGifMoodItem(
sessionId: string,
itemId: string,
data: { note?: string; order?: number }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await gifMoodService.updateGifMoodItem(itemId, authSession.user.id, data);
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'GIF_UPDATED',
{ itemId, ...data }
);
broadcastToGifMoodSession(sessionId, {
type: 'GIF_UPDATED',
payload: { itemId, ...data },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error updating gif mood item:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteGifMoodItem(sessionId: string, itemId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await gifMoodService.deleteGifMoodItem(itemId, authSession.user.id);
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'GIF_DELETED',
{ itemId, userId: authSession.user.id }
);
broadcastToGifMoodSession(sessionId, {
type: 'GIF_DELETED',
payload: { itemId, userId: authSession.user.id },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting gif mood item:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Week Rating Actions
// ============================================
export async function setGifMoodUserRating(sessionId: string, rating: number) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await gifMoodService.upsertGifMoodUserRating(sessionId, authSession.user.id, rating);
const user = await getUserById(authSession.user.id);
if (user) {
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
{ rating, userId: authSession.user.id }
);
broadcastToGifMoodSession(sessionId, {
type: 'SESSION_UPDATED',
payload: { rating, userId: authSession.user.id },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
}
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error setting gif mood user rating:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
// ============================================
// Sharing Actions
// ============================================
export async function shareGifMoodSession(
sessionId: string,
targetEmail: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const share = await gifMoodService.shareGifMoodSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing gif mood session:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function shareGifMoodSessionToTeam(
sessionId: string,
teamId: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const shares = await gifMoodService.shareGifMoodSessionToTeam(
sessionId,
authSession.user.id,
teamId,
role
);
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true, data: shares };
} catch (error) {
console.error('Error sharing gif mood session to team:', error);
const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe";
return { success: false, error: message };
}
}
export async function removeGifMoodShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await gifMoodService.removeGifMoodShare(sessionId, authSession.user.id, shareUserId);
revalidatePath(`/gif-mood/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing gif mood share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

View File

@@ -1,8 +1,10 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath, revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import * as motivatorsService from '@/services/moving-motivators'; import * as motivatorsService from '@/services/moving-motivators';
import { sessionsListTag } from '@/lib/cache-tags';
import { broadcastToMotivatorSession } from '@/app/api/motivators/[id]/subscribe/route';
// ============================================ // ============================================
// Session Actions // Session Actions
@@ -54,9 +56,11 @@ export async function updateMotivatorSession(
data data
); );
broadcastToMotivatorSession(sessionId, { type: 'SESSION_UPDATED' });
revalidatePath(`/motivators/${sessionId}`); revalidatePath(`/motivators/${sessionId}`);
revalidatePath('/motivators'); revalidatePath('/motivators');
revalidatePath('/sessions'); // Also revalidate unified workshops page revalidatePath('/sessions'); // Also revalidate unified workshops page
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating motivator session:', error); console.error('Error updating motivator session:', error);
@@ -74,6 +78,7 @@ export async function deleteMotivatorSession(sessionId: string) {
await motivatorsService.deleteMotivatorSession(sessionId, authSession.user.id); await motivatorsService.deleteMotivatorSession(sessionId, authSession.user.id);
revalidatePath('/motivators'); revalidatePath('/motivators');
revalidatePath('/sessions'); // Also revalidate unified workshops page revalidatePath('/sessions'); // Also revalidate unified workshops page
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error deleting motivator session:', error); console.error('Error deleting motivator session:', error);
@@ -121,6 +126,7 @@ export async function updateMotivatorCard(
); );
} }
broadcastToMotivatorSession(sessionId, { type: 'CARD_UPDATED' });
revalidatePath(`/motivators/${sessionId}`); revalidatePath(`/motivators/${sessionId}`);
return { success: true, data: card }; return { success: true, data: card };
} catch (error) { } catch (error) {
@@ -152,6 +158,7 @@ export async function reorderMotivatorCards(sessionId: string, cardIds: string[]
{ cardIds } { cardIds }
); );
broadcastToMotivatorSession(sessionId, { type: 'CARDS_REORDERED' });
revalidatePath(`/motivators/${sessionId}`); revalidatePath(`/motivators/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@@ -1,8 +1,10 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath, revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import * as sessionsService from '@/services/sessions'; import * as sessionsService from '@/services/sessions';
import { sessionsListTag } from '@/lib/cache-tags';
import { broadcastToSession } from '@/app/api/sessions/[id]/subscribe/route';
export async function updateSessionTitle(sessionId: string, title: string) { export async function updateSessionTitle(sessionId: string, title: string) {
const session = await auth(); const session = await auth();
@@ -28,8 +30,10 @@ export async function updateSessionTitle(sessionId: string, title: string) {
title: title.trim(), title: title.trim(),
}); });
broadcastToSession(sessionId, { type: 'SESSION_UPDATED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(session.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating session title:', error); console.error('Error updating session title:', error);
@@ -61,8 +65,10 @@ export async function updateSessionCollaborator(sessionId: string, collaborator:
collaborator: collaborator.trim(), collaborator: collaborator.trim(),
}); });
broadcastToSession(sessionId, { type: 'SESSION_UPDATED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(session.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating session collaborator:', error); console.error('Error updating session collaborator:', error);
@@ -106,8 +112,10 @@ export async function updateSwotSession(
updateData updateData
); );
broadcastToSession(sessionId, { type: 'SESSION_UPDATED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(session.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating session:', error); console.error('Error updating session:', error);
@@ -129,6 +137,7 @@ export async function deleteSwotSession(sessionId: string) {
} }
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(session.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error deleting session:', error); console.error('Error deleting session:', error);

View File

@@ -0,0 +1,49 @@
'use server';
import { auth } from '@/lib/auth';
import { SESSIONS_PAGE_SIZE } from '@/lib/types';
import { withWorkshopType } from '@/lib/workshops';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
import { getWeatherSessionsByUserId } from '@/services/weather';
import { getGifMoodSessionsByUserId } from '@/services/gif-mood';
import type { WorkshopTypeId } from '@/lib/workshops';
export async function loadMoreSessions(type: WorkshopTypeId, offset: number) {
const session = await auth();
if (!session?.user?.id) return null;
const userId = session.user.id;
const limit = SESSIONS_PAGE_SIZE;
switch (type) {
case 'swot': {
const all = await getSessionsByUserId(userId);
return { items: withWorkshopType(all.slice(offset, offset + limit), 'swot'), total: all.length };
}
case 'motivators': {
const all = await getMotivatorSessionsByUserId(userId);
return { items: withWorkshopType(all.slice(offset, offset + limit), 'motivators'), total: all.length };
}
case 'year-review': {
const all = await getYearReviewSessionsByUserId(userId);
return { items: withWorkshopType(all.slice(offset, offset + limit), 'year-review'), total: all.length };
}
case 'weekly-checkin': {
const all = await getWeeklyCheckInSessionsByUserId(userId);
return { items: withWorkshopType(all.slice(offset, offset + limit), 'weekly-checkin'), total: all.length };
}
case 'weather': {
const all = await getWeatherSessionsByUserId(userId);
return { items: withWorkshopType(all.slice(offset, offset + limit), 'weather'), total: all.length };
}
case 'gif-mood': {
const all = await getGifMoodSessionsByUserId(userId);
return { items: withWorkshopType(all.slice(offset, offset + limit), 'gif-mood'), total: all.length };
}
default:
return null;
}
}

View File

@@ -3,6 +3,7 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import * as sessionsService from '@/services/sessions'; import * as sessionsService from '@/services/sessions';
import { broadcastToSession } from '@/app/api/sessions/[id]/subscribe/route';
import type { SwotCategory } from '@prisma/client'; import type { SwotCategory } from '@prisma/client';
// ============================================ // ============================================
@@ -31,6 +32,7 @@ export async function createSwotItem(
category: item.category, category: item.category,
}); });
broadcastToSession(sessionId, { type: 'ITEM_CREATED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item }; return { success: true, data: item };
} catch (error) { } catch (error) {
@@ -61,6 +63,7 @@ export async function updateSwotItem(
...data, ...data,
}); });
broadcastToSession(sessionId, { type: 'ITEM_UPDATED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item }; return { success: true, data: item };
} catch (error) { } catch (error) {
@@ -86,6 +89,7 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
itemId, itemId,
}); });
broadcastToSession(sessionId, { type: 'ITEM_DELETED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -114,6 +118,7 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
duplicatedFrom: itemId, duplicatedFrom: itemId,
}); });
broadcastToSession(sessionId, { type: 'ITEM_CREATED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item }; return { success: true, data: item };
} catch (error) { } catch (error) {
@@ -146,6 +151,7 @@ export async function moveSwotItem(
newOrder, newOrder,
}); });
broadcastToSession(sessionId, { type: 'ITEM_MOVED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item }; return { success: true, data: item };
} catch (error) { } catch (error) {
@@ -185,6 +191,7 @@ export async function createAction(
linkedItemIds: data.linkedItemIds, linkedItemIds: data.linkedItemIds,
}); });
broadcastToSession(sessionId, { type: 'ACTION_CREATED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: action }; return { success: true, data: action };
} catch (error) { } catch (error) {
@@ -221,6 +228,7 @@ export async function updateAction(
...data, ...data,
}); });
broadcastToSession(sessionId, { type: 'ACTION_UPDATED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: action }; return { success: true, data: action };
} catch (error) { } catch (error) {
@@ -246,6 +254,7 @@ export async function deleteAction(actionId: string, sessionId: string) {
actionId, actionId,
}); });
broadcastToSession(sessionId, { type: 'ACTION_DELETED' });
revalidatePath(`/sessions/${sessionId}`); revalidatePath(`/sessions/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@@ -1,8 +1,9 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath, revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import * as weatherService from '@/services/weather'; import * as weatherService from '@/services/weather';
import { sessionsListTag } from '@/lib/cache-tags';
import { getUserById } from '@/services/auth'; import { getUserById } from '@/services/auth';
import { broadcastToWeatherSession } from '@/app/api/weather/[id]/subscribe/route'; import { broadcastToWeatherSession } from '@/app/api/weather/[id]/subscribe/route';
@@ -20,6 +21,7 @@ export async function createWeatherSession(data: { title: string; date?: Date })
const weatherSession = await weatherService.createWeatherSession(session.user.id, data); const weatherSession = await weatherService.createWeatherSession(session.user.id, data);
revalidatePath('/weather'); revalidatePath('/weather');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(session.user.id), 'default');
return { success: true, data: weatherSession }; return { success: true, data: weatherSession };
} catch (error) { } catch (error) {
console.error('Error creating weather session:', error); console.error('Error creating weather session:', error);
@@ -65,6 +67,7 @@ export async function updateWeatherSession(
revalidatePath(`/weather/${sessionId}`); revalidatePath(`/weather/${sessionId}`);
revalidatePath('/weather'); revalidatePath('/weather');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating weather session:', error); console.error('Error updating weather session:', error);
@@ -82,6 +85,7 @@ export async function deleteWeatherSession(sessionId: string) {
await weatherService.deleteWeatherSession(sessionId, authSession.user.id); await weatherService.deleteWeatherSession(sessionId, authSession.user.id);
revalidatePath('/weather'); revalidatePath('/weather');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error deleting weather session:', error); console.error('Error deleting weather session:', error);
@@ -115,7 +119,11 @@ export async function createOrUpdateWeatherEntry(
} }
try { try {
const entry = await weatherService.createOrUpdateWeatherEntry(sessionId, authSession.user.id, data); const entry = await weatherService.createOrUpdateWeatherEntry(
sessionId,
authSession.user.id,
data
);
// Get user info for broadcast // Get user info for broadcast
const user = await getUserById(authSession.user.id); const user = await getUserById(authSession.user.id);
@@ -124,7 +132,8 @@ export async function createOrUpdateWeatherEntry(
} }
// Emit event for real-time sync // Emit event for real-time sync
const eventType = entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED'; const eventType =
entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED';
const event = await weatherService.createWeatherSessionEvent( const event = await weatherService.createWeatherSessionEvent(
sessionId, sessionId,
authSession.user.id, authSession.user.id,
@@ -254,7 +263,7 @@ export async function shareWeatherSessionToTeam(
return { success: true, data: shares }; return { success: true, data: shares };
} catch (error) { } catch (error) {
console.error('Error sharing weather session to team:', error); console.error('Error sharing weather session to team:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage à l\'équipe'; const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe";
return { success: false, error: message }; return { success: false, error: message };
} }
} }

View File

@@ -1,8 +1,10 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath, revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import * as weeklyCheckInService from '@/services/weekly-checkin'; import * as weeklyCheckInService from '@/services/weekly-checkin';
import { sessionsListTag } from '@/lib/cache-tags';
import { broadcastToWeeklyCheckInSession } from '@/app/api/weekly-checkin/[id]/subscribe/route';
import type { WeeklyCheckInCategory, Emotion } from '@prisma/client'; import type { WeeklyCheckInCategory, Emotion } from '@prisma/client';
// ============================================ // ============================================
@@ -36,6 +38,7 @@ export async function createWeeklyCheckInSession(data: {
} }
revalidatePath('/weekly-checkin'); revalidatePath('/weekly-checkin');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(session.user.id), 'default');
return { success: true, data: weeklyCheckInSession }; return { success: true, data: weeklyCheckInSession };
} catch (error) { } catch (error) {
console.error('Error creating weekly check-in session:', error); console.error('Error creating weekly check-in session:', error);
@@ -63,9 +66,11 @@ export async function updateWeeklyCheckInSession(
data data
); );
broadcastToWeeklyCheckInSession(sessionId, { type: 'SESSION_UPDATED' });
revalidatePath(`/weekly-checkin/${sessionId}`); revalidatePath(`/weekly-checkin/${sessionId}`);
revalidatePath('/weekly-checkin'); revalidatePath('/weekly-checkin');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating weekly check-in session:', error); console.error('Error updating weekly check-in session:', error);
@@ -83,6 +88,7 @@ export async function deleteWeeklyCheckInSession(sessionId: string) {
await weeklyCheckInService.deleteWeeklyCheckInSession(sessionId, authSession.user.id); await weeklyCheckInService.deleteWeeklyCheckInSession(sessionId, authSession.user.id);
revalidatePath('/weekly-checkin'); revalidatePath('/weekly-checkin');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error deleting weekly check-in session:', error); console.error('Error deleting weekly check-in session:', error);
@@ -128,6 +134,7 @@ export async function createWeeklyCheckInItem(
} }
); );
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_CREATED' });
revalidatePath(`/weekly-checkin/${sessionId}`); revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true, data: item }; return { success: true, data: item };
} catch (error) { } catch (error) {
@@ -169,6 +176,7 @@ export async function updateWeeklyCheckInItem(
} }
); );
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_UPDATED' });
revalidatePath(`/weekly-checkin/${sessionId}`); revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true, data: item }; return { success: true, data: item };
} catch (error) { } catch (error) {
@@ -203,6 +211,7 @@ export async function deleteWeeklyCheckInItem(itemId: string, sessionId: string)
{ itemId } { itemId }
); );
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_DELETED' });
revalidatePath(`/weekly-checkin/${sessionId}`); revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -246,6 +255,7 @@ export async function moveWeeklyCheckInItem(
} }
); );
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_MOVED' });
revalidatePath(`/weekly-checkin/${sessionId}`); revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -284,6 +294,7 @@ export async function reorderWeeklyCheckInItems(
{ category, itemIds } { category, itemIds }
); );
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEMS_REORDERED' });
revalidatePath(`/weekly-checkin/${sessionId}`); revalidatePath(`/weekly-checkin/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@@ -1,8 +1,10 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath, revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import * as yearReviewService from '@/services/year-review'; import * as yearReviewService from '@/services/year-review';
import { sessionsListTag } from '@/lib/cache-tags';
import { broadcastToYearReviewSession } from '@/app/api/year-review/[id]/subscribe/route';
import type { YearReviewCategory } from '@prisma/client'; import type { YearReviewCategory } from '@prisma/client';
// ============================================ // ============================================
@@ -36,6 +38,7 @@ export async function createYearReviewSession(data: {
} }
revalidatePath('/year-review'); revalidatePath('/year-review');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(session.user.id), 'default');
return { success: true, data: yearReviewSession }; return { success: true, data: yearReviewSession };
} catch (error) { } catch (error) {
console.error('Error creating year review session:', error); console.error('Error creating year review session:', error);
@@ -63,9 +66,11 @@ export async function updateYearReviewSession(
data data
); );
broadcastToYearReviewSession(sessionId, { type: 'SESSION_UPDATED' });
revalidatePath(`/year-review/${sessionId}`); revalidatePath(`/year-review/${sessionId}`);
revalidatePath('/year-review'); revalidatePath('/year-review');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating year review session:', error); console.error('Error updating year review session:', error);
@@ -83,6 +88,7 @@ export async function deleteYearReviewSession(sessionId: string) {
await yearReviewService.deleteYearReviewSession(sessionId, authSession.user.id); await yearReviewService.deleteYearReviewSession(sessionId, authSession.user.id);
revalidatePath('/year-review'); revalidatePath('/year-review');
revalidatePath('/sessions'); revalidatePath('/sessions');
revalidateTag(sessionsListTag(authSession.user.id), 'default');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error deleting year review session:', error); console.error('Error deleting year review session:', error);
@@ -104,10 +110,7 @@ export async function createYearReviewItem(
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -127,6 +130,7 @@ export async function createYearReviewItem(
} }
); );
broadcastToYearReviewSession(sessionId, { type: 'ITEM_CREATED' });
revalidatePath(`/year-review/${sessionId}`); revalidatePath(`/year-review/${sessionId}`);
return { success: true, data: item }; return { success: true, data: item };
} catch (error) { } catch (error) {
@@ -146,10 +150,7 @@ export async function updateYearReviewItem(
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -168,6 +169,7 @@ export async function updateYearReviewItem(
} }
); );
broadcastToYearReviewSession(sessionId, { type: 'ITEM_UPDATED' });
revalidatePath(`/year-review/${sessionId}`); revalidatePath(`/year-review/${sessionId}`);
return { success: true, data: item }; return { success: true, data: item };
} catch (error) { } catch (error) {
@@ -183,10 +185,7 @@ export async function deleteYearReviewItem(itemId: string, sessionId: string) {
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -202,6 +201,7 @@ export async function deleteYearReviewItem(itemId: string, sessionId: string) {
{ itemId } { itemId }
); );
broadcastToYearReviewSession(sessionId, { type: 'ITEM_DELETED' });
revalidatePath(`/year-review/${sessionId}`); revalidatePath(`/year-review/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -222,10 +222,7 @@ export async function moveYearReviewItem(
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -245,6 +242,7 @@ export async function moveYearReviewItem(
} }
); );
broadcastToYearReviewSession(sessionId, { type: 'ITEM_MOVED' });
revalidatePath(`/year-review/${sessionId}`); revalidatePath(`/year-review/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -264,10 +262,7 @@ export async function reorderYearReviewItems(
} }
// Check edit permission // Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession( const canEdit = await yearReviewService.canEditYearReviewSession(sessionId, authSession.user.id);
sessionId,
authSession.user.id
);
if (!canEdit) { if (!canEdit) {
return { success: false, error: 'Permission refusée' }; return { success: false, error: 'Permission refusée' };
} }
@@ -283,6 +278,7 @@ export async function reorderYearReviewItems(
{ category, itemIds } { category, itemIds }
); );
broadcastToYearReviewSession(sessionId, { type: 'ITEMS_REORDERED' });
revalidatePath(`/year-review/${sessionId}`); revalidatePath(`/year-review/${sessionId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -336,4 +332,3 @@ export async function removeYearReviewShare(sessionId: string, shareUserId: stri
return { success: false, error: 'Erreur lors de la suppression du partage' }; return { success: false, error: 'Erreur lors de la suppression du partage' };
} }
} }

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { RocketIcon } from '@/components/ui'; import { Button, Input, RocketIcon } from '@/components/ui';
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -62,42 +62,32 @@ export default function LoginPage() {
)} )}
<div className="mb-4"> <div className="mb-4">
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground"> <Input
Email
</label>
<input
id="email" id="email"
name="email" name="email"
type="email" type="email"
label="Email"
required required
autoComplete="email" autoComplete="email"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="vous@exemple.com" placeholder="vous@exemple.com"
/> />
</div> </div>
<div className="mb-6"> <div className="mb-6">
<label htmlFor="password" className="mb-2 block text-sm font-medium text-foreground"> <Input
Mot de passe
</label>
<input
id="password" id="password"
name="password" name="password"
type="password" type="password"
label="Mot de passe"
required required
autoComplete="current-password" autoComplete="current-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••" placeholder="••••••••"
/> />
</div> </div>
<button <Button type="submit" disabled={loading} loading={loading} className="w-full">
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 font-semibold text-primary-foreground transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Connexion...' : 'Se connecter'} {loading ? 'Connexion...' : 'Se connecter'}
</button> </Button>
<p className="mt-6 text-center text-sm text-muted"> <p className="mt-6 text-center text-sm text-muted">
Pas encore de compte ?{' '} Pas encore de compte ?{' '}

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { RocketIcon } from '@/components/ui'; import { Button, Input, RocketIcon } from '@/components/ui';
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
@@ -91,74 +91,55 @@ export default function RegisterPage() {
)} )}
<div className="mb-4"> <div className="mb-4">
<label htmlFor="name" className="mb-2 block text-sm font-medium text-foreground"> <Input
Nom
</label>
<input
id="name" id="name"
name="name" name="name"
type="text" type="text"
label="Nom"
autoComplete="name" autoComplete="name"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="Jean Dupont" placeholder="Jean Dupont"
/> />
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground"> <Input
Email
</label>
<input
id="email" id="email"
name="email" name="email"
type="email" type="email"
label="Email"
required required
autoComplete="email" autoComplete="email"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="vous@exemple.com" placeholder="vous@exemple.com"
/> />
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="password" className="mb-2 block text-sm font-medium text-foreground"> <Input
Mot de passe
</label>
<input
id="password" id="password"
name="password" name="password"
type="password" type="password"
label="Mot de passe"
required required
autoComplete="new-password" autoComplete="new-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••" placeholder="••••••••"
/> />
</div> </div>
<div className="mb-6"> <div className="mb-6">
<label <Input
htmlFor="confirmPassword"
className="mb-2 block text-sm font-medium text-foreground"
>
Confirmer le mot de passe
</label>
<input
id="confirmPassword" id="confirmPassword"
name="confirmPassword" name="confirmPassword"
type="password" type="password"
label="Confirmer le mot de passe"
required required
autoComplete="new-password" autoComplete="new-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••" placeholder="••••••••"
/> />
</div> </div>
<button <Button type="submit" disabled={loading} loading={loading} className="w-full">
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 font-semibold text-primary-foreground transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Création...' : 'Créer mon compte'} {loading ? 'Création...' : 'Créer mon compte'}
</button> </Button>
<p className="mt-6 text-center text-sm text-muted"> <p className="mt-6 text-center text-sm text-muted">
Déjà un compte ?{' '} Déjà un compte ?{' '}

View File

@@ -0,0 +1,65 @@
import { auth } from '@/lib/auth';
import { canAccessGifMoodSession, getGifMoodSessionEvents } from '@/services/gif-mood';
import { createBroadcaster } from '@/lib/broadcast';
export const dynamic = 'force-dynamic';
const { subscribe, broadcast } = createBroadcaster(getGifMoodSessionEvents, (event) => ({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
}));
export { broadcast as broadcastToGifMoodSession };
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
const hasAccess = await canAccessGifMoodSession(sessionId, session.user.id);
if (!hasAccess) {
return new Response('Forbidden', { status: 403 });
}
const userId = session.user.id;
let unsubscribe: () => void = () => {};
let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(ctrl) {
controller = ctrl;
const encoder = new TextEncoder();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
);
unsubscribe = subscribe(sessionId, userId, (event) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
unsubscribe();
}
});
},
cancel() {
unsubscribe();
},
});
request.signal.addEventListener('abort', () => {
unsubscribe();
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}

View File

@@ -1,10 +1,18 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { canAccessMotivatorSession, getMotivatorSessionEvents } from '@/services/moving-motivators'; import { canAccessMotivatorSession, getMotivatorSessionEvents } from '@/services/moving-motivators';
import { createBroadcaster } from '@/lib/broadcast';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
// Store active connections per session const { subscribe, broadcast } = createBroadcaster(getMotivatorSessionEvents, (event) => ({
const connections = new Map<string, Set<ReadableStreamDefaultController>>(); type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
}));
export { broadcast as broadcastToMotivatorSession };
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params; const { id: sessionId } = await params;
@@ -14,74 +22,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
// Check access
const hasAccess = await canAccessMotivatorSession(sessionId, session.user.id); const hasAccess = await canAccessMotivatorSession(sessionId, session.user.id);
if (!hasAccess) { if (!hasAccess) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }
const userId = session.user.id; const userId = session.user.id;
let lastEventTime = new Date(); let unsubscribe: () => void = () => {};
let controller: ReadableStreamDefaultController; let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({ const stream = new ReadableStream({
start(ctrl) { start(ctrl) {
controller = ctrl; controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder(); const encoder = new TextEncoder();
controller.enqueue( controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
); );
unsubscribe = subscribe(sessionId, userId, (event) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
unsubscribe();
}
});
}, },
cancel() { cancel() {
// Remove connection on close unsubscribe();
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
}, },
}); });
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getMotivatorSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
clearInterval(pollInterval); unsubscribe();
}); });
return new Response(stream, { return new Response(stream, {
@@ -92,20 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
}, },
}); });
} }
// Helper to broadcast to all connections (called from actions)
export function broadcastToMotivatorSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections) return;
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
// Connection closed, will be cleaned up
}
}
}

View File

@@ -34,7 +34,10 @@ export async function PATCH(
if (!isAdmin && !isConcernedMember) { if (!isAdmin && !isConcernedMember) {
return NextResponse.json( return NextResponse.json(
{ error: 'Seuls les administrateurs et le membre concerné peuvent mettre à jour les Key Results' }, {
error:
'Seuls les administrateurs et le membre concerné peuvent mettre à jour les Key Results',
},
{ status: 403 } { status: 403 }
); );
} }
@@ -51,10 +54,8 @@ export async function PATCH(
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error('Error updating key result:', error); console.error('Error updating key result:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result'; const errorMessage =
return NextResponse.json( error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
{ error: errorMessage }, return NextResponse.json({ error: errorMessage }, { status: 500 });
{ status: 500 }
);
} }
} }

View File

@@ -40,10 +40,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
}); });
} catch (error) { } catch (error) {
console.error('Error fetching OKR:', error); console.error('Error fetching OKR:', error);
return NextResponse.json( return NextResponse.json({ error: "Erreur lors de la récupération de l'OKR" }, { status: 500 });
{ error: 'Erreur lors de la récupération de l\'OKR' },
{ status: 500 }
);
} }
} }
@@ -65,19 +62,28 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id); const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
const isConcernedMember = okr.teamMember.userId === session.user.id; const isConcernedMember = okr.teamMember.userId === session.user.id;
if (!isAdmin && !isConcernedMember) { if (!isAdmin && !isConcernedMember) {
return NextResponse.json({ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs et le membre concerné peuvent modifier les OKRs' },
{ status: 403 }
);
} }
const body: UpdateOKRInput & { const body: UpdateOKRInput & {
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
keyResultsUpdates?: { keyResultsUpdates?: {
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>; create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
update?: Array<{ id: string; title?: string; targetValue?: number; unit?: string; order?: number }>; update?: Array<{
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
}>;
delete?: string[]; delete?: string[];
}; };
} = await request.json(); } = await request.json();
// Convert date strings to Date objects if provided // Convert date strings to Date objects if provided
const updateData: UpdateOKRInput = { ...body }; const updateData: UpdateOKRInput = { ...body };
if (body.startDate) { if (body.startDate) {
@@ -102,11 +108,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error('Error updating OKR:', error); console.error('Error updating OKR:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour de l\'OKR'; const errorMessage =
return NextResponse.json( error instanceof Error ? error.message : "Erreur lors de la mise à jour de l'OKR";
{ error: errorMessage }, return NextResponse.json({ error: errorMessage }, { status: 500 });
{ status: 500 }
);
} }
} }
@@ -127,7 +131,10 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
// Check if user is admin of the team // Check if user is admin of the team
const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id); const isAdmin = await isTeamAdmin(okr.teamMember.team.id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent supprimer les OKRs' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent supprimer les OKRs' },
{ status: 403 }
);
} }
await deleteOKR(id); await deleteOKR(id);
@@ -135,10 +142,8 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error deleting OKR:', error); console.error('Error deleting OKR:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la suppression de l\'OKR'; const errorMessage =
return NextResponse.json( error instanceof Error ? error.message : "Erreur lors de la suppression de l'OKR";
{ error: errorMessage }, return NextResponse.json({ error: errorMessage }, { status: 500 });
{ status: 500 }
);
} }
} }

View File

@@ -1,10 +1,18 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { canAccessSession, getSessionEvents } from '@/services/sessions'; import { canAccessSession, getSessionEvents } from '@/services/sessions';
import { createBroadcaster } from '@/lib/broadcast';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
// Store active connections per session const { subscribe, broadcast } = createBroadcaster(getSessionEvents, (event) => ({
const connections = new Map<string, Set<ReadableStreamDefaultController>>(); type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
}));
export { broadcast as broadcastToSession };
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params; const { id: sessionId } = await params;
@@ -14,74 +22,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
// Check access
const hasAccess = await canAccessSession(sessionId, session.user.id); const hasAccess = await canAccessSession(sessionId, session.user.id);
if (!hasAccess) { if (!hasAccess) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }
const userId = session.user.id; const userId = session.user.id;
let lastEventTime = new Date(); let unsubscribe: () => void = () => {};
let controller: ReadableStreamDefaultController; let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({ const stream = new ReadableStream({
start(ctrl) { start(ctrl) {
controller = ctrl; controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder(); const encoder = new TextEncoder();
controller.enqueue( controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
); );
unsubscribe = subscribe(sessionId, userId, (event) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
unsubscribe();
}
});
}, },
cancel() { cancel() {
// Remove connection on close unsubscribe();
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
}, },
}); });
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId, // Include userId for client-side filtering
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
clearInterval(pollInterval); unsubscribe();
}); });
return new Response(stream, { return new Response(stream, {
@@ -92,20 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
}, },
}); });
} }
// Helper to broadcast to all connections (called from actions)
export function broadcastToSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections) return;
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
// Connection closed, will be cleaned up
}
}
}

View File

@@ -1,7 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { shareSession } from '@/services/sessions'; import { shareSession } from '@/services/sessions';
import { sessionsListTag } from '@/lib/cache-tags';
export async function GET() { export async function GET() {
try { try {
@@ -63,6 +65,7 @@ export async function POST(request: Request) {
console.error('Auto-share failed:', shareError); console.error('Auto-share failed:', shareError);
} }
revalidateTag(sessionsListTag(session.user.id), 'default');
return NextResponse.json(newSession, { status: 201 }); return NextResponse.json(newSession, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error creating session:', error); console.error('Error creating session:', error);

View File

@@ -15,7 +15,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent ajouter des membres' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent ajouter des membres' },
{ status: 403 }
);
} }
const body: AddTeamMemberInput = await request.json(); const body: AddTeamMemberInput = await request.json();
@@ -30,11 +33,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json(member, { status: 201 }); return NextResponse.json(member, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error adding team member:', error); console.error('Error adding team member:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout du membre'; const errorMessage =
return NextResponse.json( error instanceof Error ? error.message : "Erreur lors de l'ajout du membre";
{ error: errorMessage }, return NextResponse.json({ error: errorMessage }, { status: 500 });
{ status: 500 }
);
} }
} }
@@ -50,7 +51,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier les rôles' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent modifier les rôles' },
{ status: 403 }
);
} }
const body: UpdateMemberRoleInput & { userId: string } = await request.json(); const body: UpdateMemberRoleInput & { userId: string } = await request.json();
@@ -65,10 +69,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json(member); return NextResponse.json(member);
} catch (error) { } catch (error) {
console.error('Error updating member role:', error); console.error('Error updating member role:', error);
return NextResponse.json( return NextResponse.json({ error: 'Erreur lors de la mise à jour du rôle' }, { status: 500 });
{ error: 'Erreur lors de la mise à jour du rôle' },
{ status: 500 }
);
} }
} }
@@ -84,7 +85,10 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent retirer des membres' }, { status: 403 }); return NextResponse.json(
{ error: 'Seuls les administrateurs peuvent retirer des membres' },
{ status: 403 }
);
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -99,10 +103,6 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error removing team member:', error); console.error('Error removing team member:', error);
return NextResponse.json( return NextResponse.json({ error: 'Erreur lors de la suppression du membre' }, { status: 500 });
{ error: 'Erreur lors de la suppression du membre' },
{ status: 500 }
);
} }
} }

View File

@@ -28,7 +28,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
} catch (error) { } catch (error) {
console.error('Error fetching team:', error); console.error('Error fetching team:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Erreur lors de la récupération de l\'équipe' }, { error: "Erreur lors de la récupération de l'équipe" },
{ status: 500 } { status: 500 }
); );
} }
@@ -46,7 +46,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent modifier l\'équipe' }, { status: 403 }); return NextResponse.json(
{ error: "Seuls les administrateurs peuvent modifier l'équipe" },
{ status: 403 }
);
} }
const body: UpdateTeamInput = await request.json(); const body: UpdateTeamInput = await request.json();
@@ -56,7 +59,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
} catch (error) { } catch (error) {
console.error('Error updating team:', error); console.error('Error updating team:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Erreur lors de la mise à jour de l\'équipe' }, { error: "Erreur lors de la mise à jour de l'équipe" },
{ status: 500 } { status: 500 }
); );
} }
@@ -74,7 +77,10 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
// Check if user is admin // Check if user is admin
const isAdmin = await isTeamAdmin(id, session.user.id); const isAdmin = await isTeamAdmin(id, session.user.id);
if (!isAdmin) { if (!isAdmin) {
return NextResponse.json({ error: 'Seuls les administrateurs peuvent supprimer l\'équipe' }, { status: 403 }); return NextResponse.json(
{ error: "Seuls les administrateurs peuvent supprimer l'équipe" },
{ status: 403 }
);
} }
await deleteTeam(id); await deleteTeam(id);
@@ -83,9 +89,8 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
} catch (error) { } catch (error) {
console.error('Error deleting team:', error); console.error('Error deleting team:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Erreur lors de la suppression de l\'équipe' }, { error: "Erreur lors de la suppression de l'équipe" },
{ status: 500 } { status: 500 }
); );
} }
} }

View File

@@ -35,7 +35,7 @@ export async function POST(request: Request) {
const { name, description } = body; const { name, description } = body;
if (!name) { if (!name) {
return NextResponse.json({ error: 'Le nom de l\'équipe est requis' }, { status: 400 }); return NextResponse.json({ error: "Le nom de l'équipe est requis" }, { status: 400 });
} }
const team = await createTeam(name, description || null, session.user.id); const team = await createTeam(name, description || null, session.user.id);
@@ -43,10 +43,6 @@ export async function POST(request: Request) {
return NextResponse.json(team, { status: 201 }); return NextResponse.json(team, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error creating team:', error); console.error('Error creating team:', error);
return NextResponse.json( return NextResponse.json({ error: "Erreur lors de la création de l'équipe" }, { status: 500 });
{ error: 'Erreur lors de la création de l\'équipe' },
{ status: 500 }
);
} }
} }

View File

@@ -30,4 +30,3 @@ export async function GET() {
); );
} }
} }

View File

@@ -1,13 +1,18 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { import { canAccessWeatherSession, getWeatherSessionEvents } from '@/services/weather';
canAccessWeatherSession, import { createBroadcaster } from '@/lib/broadcast';
getWeatherSessionEvents,
} from '@/services/weather';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
// Store active connections per session const { subscribe, broadcast } = createBroadcaster(getWeatherSessionEvents, (event) => ({
const connections = new Map<string, Set<ReadableStreamDefaultController>>(); type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
}));
export { broadcast as broadcastToWeatherSession };
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params; const { id: sessionId } = await params;
@@ -17,74 +22,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
// Check access
const hasAccess = await canAccessWeatherSession(sessionId, session.user.id); const hasAccess = await canAccessWeatherSession(sessionId, session.user.id);
if (!hasAccess) { if (!hasAccess) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }
const userId = session.user.id; const userId = session.user.id;
let lastEventTime = new Date(); let unsubscribe: () => void = () => {};
let controller: ReadableStreamDefaultController; let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({ const stream = new ReadableStream({
start(ctrl) { start(ctrl) {
controller = ctrl; controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder(); const encoder = new TextEncoder();
controller.enqueue( controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
); );
unsubscribe = subscribe(sessionId, userId, (event) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
unsubscribe();
}
});
}, },
cancel() { cancel() {
// Remove connection on close unsubscribe();
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
}, },
}); });
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getWeatherSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
clearInterval(pollInterval); unsubscribe();
}); });
return new Response(stream, { return new Response(stream, {
@@ -95,41 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
}, },
}); });
} }
// Helper to broadcast to all connections (called from actions)
export function broadcastToWeatherSession(sessionId: string, event: object) {
try {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
// No active connections, event will be picked up by polling
console.log(`[SSE Broadcast] No connections for session ${sessionId}, will be picked up by polling`);
return;
}
console.log(`[SSE Broadcast] Broadcasting to ${sessionConnections.size} connections for session ${sessionId}`);
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
let sentCount = 0;
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
sentCount++;
} catch (error) {
// Connection might be closed, remove it
console.log(`[SSE Broadcast] Failed to send, removing connection:`, error);
sessionConnections.delete(controller);
}
}
console.log(`[SSE Broadcast] Sent to ${sentCount} connections`);
// Clean up empty sets
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
} catch (error) {
console.error('[SSE Broadcast] Error broadcasting:', error);
}
}

View File

@@ -3,11 +3,19 @@ import {
canAccessWeeklyCheckInSession, canAccessWeeklyCheckInSession,
getWeeklyCheckInSessionEvents, getWeeklyCheckInSessionEvents,
} from '@/services/weekly-checkin'; } from '@/services/weekly-checkin';
import { createBroadcaster } from '@/lib/broadcast';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
// Store active connections per session const { subscribe, broadcast } = createBroadcaster(getWeeklyCheckInSessionEvents, (event) => ({
const connections = new Map<string, Set<ReadableStreamDefaultController>>(); type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
}));
export { broadcast as broadcastToWeeklyCheckInSession };
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params; const { id: sessionId } = await params;
@@ -17,74 +25,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
// Check access
const hasAccess = await canAccessWeeklyCheckInSession(sessionId, session.user.id); const hasAccess = await canAccessWeeklyCheckInSession(sessionId, session.user.id);
if (!hasAccess) { if (!hasAccess) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }
const userId = session.user.id; const userId = session.user.id;
let lastEventTime = new Date(); let unsubscribe: () => void = () => {};
let controller: ReadableStreamDefaultController; let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({ const stream = new ReadableStream({
start(ctrl) { start(ctrl) {
controller = ctrl; controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder(); const encoder = new TextEncoder();
controller.enqueue( controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
); );
unsubscribe = subscribe(sessionId, userId, (event) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
unsubscribe();
}
});
}, },
cancel() { cancel() {
// Remove connection on close unsubscribe();
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
}, },
}); });
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getWeeklyCheckInSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
clearInterval(pollInterval); unsubscribe();
}); });
return new Response(stream, { return new Response(stream, {
@@ -95,28 +66,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
}, },
}); });
} }
// Helper to broadcast to all connections (called from actions)
export function broadcastToWeeklyCheckInSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
return;
}
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
// Connection might be closed, remove it
sessionConnections.delete(controller);
}
}
// Clean up empty sets
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
}

View File

@@ -1,13 +1,18 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { import { canAccessYearReviewSession, getYearReviewSessionEvents } from '@/services/year-review';
canAccessYearReviewSession, import { createBroadcaster } from '@/lib/broadcast';
getYearReviewSessionEvents,
} from '@/services/year-review';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
// Store active connections per session const { subscribe, broadcast } = createBroadcaster(getYearReviewSessionEvents, (event) => ({
const connections = new Map<string, Set<ReadableStreamDefaultController>>(); type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
}));
export { broadcast as broadcastToYearReviewSession };
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params; const { id: sessionId } = await params;
@@ -17,74 +22,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
// Check access
const hasAccess = await canAccessYearReviewSession(sessionId, session.user.id); const hasAccess = await canAccessYearReviewSession(sessionId, session.user.id);
if (!hasAccess) { if (!hasAccess) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }
const userId = session.user.id; const userId = session.user.id;
let lastEventTime = new Date(); let unsubscribe: () => void = () => {};
let controller: ReadableStreamDefaultController; let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({ const stream = new ReadableStream({
start(ctrl) { start(ctrl) {
controller = ctrl; controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder(); const encoder = new TextEncoder();
controller.enqueue( controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
); );
unsubscribe = subscribe(sessionId, userId, (event) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
unsubscribe();
}
});
}, },
cancel() { cancel() {
// Remove connection on close unsubscribe();
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
}, },
}); });
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getYearReviewSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
clearInterval(pollInterval); unsubscribe();
}); });
return new Response(stream, { return new Response(stream, {
@@ -95,29 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
}, },
}); });
} }
// Helper to broadcast to all connections (called from actions)
export function broadcastToYearReviewSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
return;
}
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
// Connection might be closed, remove it
sessionConnections.delete(controller);
}
}
// Clean up empty sets
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
}

View File

@@ -0,0 +1,525 @@
'use client';
import { useState } from 'react';
import {
Avatar,
Badge,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
CollaboratorDisplay,
DateInput,
Disclosure,
DropdownMenu,
EditableGifMoodTitle,
EditableMotivatorTitle,
EditableSessionTitle,
EditableTitle,
EditableWeatherTitle,
EditableWeeklyCheckInTitle,
EditableYearReviewTitle,
InlineFormActions,
Input,
IconCheck,
IconClose,
IconDuplicate,
IconEdit,
IconButton,
IconPlus,
IconTrash,
Modal,
ModalFooter,
PageHeader,
ParticipantInput,
RocketIcon,
Select,
SegmentedControl,
SessionPageHeader,
Textarea,
ToggleGroup,
FormField,
NumberInput,
} from '@/components/ui';
const BUTTON_VARIANTS = [
'primary',
'secondary',
'outline',
'ghost',
'destructive',
'brand',
] as const;
const BUTTON_SIZES = ['sm', 'md', 'lg'] as const;
const BADGE_VARIANTS = [
'default',
'primary',
'strength',
'weakness',
'opportunity',
'threat',
'success',
'warning',
'destructive',
'accent',
] as const;
const SELECT_OPTIONS = [
{ value: 'editor', label: 'Editeur' },
{ value: 'viewer', label: 'Lecteur' },
{ value: 'admin', label: 'Admin' },
];
const SECTION_LINKS = [
{ id: 'buttons', label: 'Buttons' },
{ id: 'badges', label: 'Badges' },
{ id: 'icon-button', label: 'IconButton' },
{ id: 'form-inputs', label: 'Form Inputs' },
{ id: 'select-toggle', label: 'Select & Toggle' },
{ id: 'form-field', label: 'FormField / Date / Number' },
{ id: 'cards', label: 'Cards' },
{ id: 'avatars', label: 'Avatar & Collaborators' },
{ id: 'disclosure-dropdown', label: 'Disclosure & Dropdown' },
{ id: 'menu', label: 'Menu' },
{ id: 'editable-titles', label: 'Editable Titles' },
{ id: 'session-header', label: 'Session Header' },
{ id: 'participant-input', label: 'ParticipantInput' },
{ id: 'icons', label: 'Icons' },
{ id: 'modal', label: 'Modal' },
] as const;
export default function DesignSystemPage() {
const [modalOpen, setModalOpen] = useState(false);
const [toggleValue, setToggleValue] = useState<'cards' | 'table' | 'list'>('cards');
const [selectMd, setSelectMd] = useState('editor');
const [selectSm, setSelectSm] = useState('viewer');
const [selectXs, setSelectXs] = useState('admin');
const [selectLg, setSelectLg] = useState('editor');
const [menuCount, setMenuCount] = useState(0);
return (
<main className="mx-auto max-w-7xl px-4 py-8">
<PageHeader
emoji="🎨"
title="Design System"
subtitle="Guide visuel des composants UI et de leurs variantes"
actions={
<Button variant="brand" size="sm">
Action principale
</Button>
}
/>
<div className="grid items-start gap-8" style={{ gridTemplateColumns: '240px minmax(0, 1fr)' }}>
<aside>
<Card className="sticky top-20 p-4">
<p className="mb-3 text-sm font-medium text-foreground">Menu de la page</p>
<nav className="flex flex-col gap-1.5">
{SECTION_LINKS.map((section) => (
<a
key={section.id}
href={`#${section.id}`}
className="rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-card-hover hover:text-foreground"
>
{section.label}
</a>
))}
</nav>
</Card>
</aside>
<div className="space-y-8">
<Card id="buttons" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Buttons</h2>
<div className="space-y-4">
{BUTTON_SIZES.map((size) => (
<div key={size} className="flex flex-wrap items-center gap-3">
<span className="w-12 text-xs uppercase tracking-wide text-muted">{size}</span>
{BUTTON_VARIANTS.map((variant) => (
<Button key={`${size}-${variant}`} variant={variant} size={size}>
{variant}
</Button>
))}
</div>
))}
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
<Button loading>Chargement</Button>
<Button disabled>Desactive</Button>
</div>
</div>
</Card>
<Card id="badges" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Badges</h2>
<div className="flex flex-wrap gap-2">
{BADGE_VARIANTS.map((variant) => (
<Badge key={variant} variant={variant}>
{variant}
</Badge>
))}
</div>
</Card>
<Card id="icon-button" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">IconButton</h2>
<div className="flex flex-wrap items-center gap-2">
<IconButton icon={<IconEdit />} label="Edit" />
<IconButton icon={<IconDuplicate />} label="Duplicate" variant="primary" />
<IconButton icon={<IconTrash />} label="Delete" variant="destructive" />
</div>
</Card>
<Card id="form-inputs" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Form Inputs</h2>
<div className="grid gap-4 md:grid-cols-2">
<Input label="Input standard" placeholder="Votre texte" />
<Input label="Input avec erreur" defaultValue="Valeur invalide" error="Champ invalide" />
<Textarea label="Textarea standard" placeholder="Votre description" rows={3} />
<Textarea
label="Textarea avec erreur"
defaultValue="Texte"
rows={3}
error="Description trop courte"
/>
</div>
</Card>
<Card id="select-toggle" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Select & ToggleGroup</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<Select
label="Select XS"
size="xs"
value={selectXs}
onChange={(e) => setSelectXs(e.target.value)}
options={SELECT_OPTIONS}
/>
<Select
label="Select SM"
size="sm"
value={selectSm}
onChange={(e) => setSelectSm(e.target.value)}
options={SELECT_OPTIONS}
/>
<Select
label="Select MD"
size="md"
value={selectMd}
onChange={(e) => setSelectMd(e.target.value)}
options={SELECT_OPTIONS}
/>
<Select
label="Select LG"
size="lg"
value={selectLg}
onChange={(e) => setSelectLg(e.target.value)}
options={SELECT_OPTIONS}
/>
</div>
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">Toggle group</p>
<ToggleGroup
value={toggleValue}
onChange={setToggleValue}
options={[
{ value: 'cards', label: 'Cards' },
{ value: 'table', label: 'Table' },
{ value: 'list', label: 'List' },
]}
/>
<p className="text-sm text-muted">Valeur active: {toggleValue}</p>
<p className="pt-2 text-sm font-medium text-foreground">Segmented control</p>
<SegmentedControl
value={toggleValue}
onChange={setToggleValue}
options={[
{ value: 'cards', label: 'Cards' },
{ value: 'table', label: 'Table' },
{ value: 'list', label: 'List' },
]}
/>
</div>
</div>
</Card>
<Card id="form-field" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">FormField / Date / Number</h2>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="FormField">
<Input placeholder="Control custom" />
</FormField>
<DateInput label="DateInput" defaultValue="2026-03-03" />
<NumberInput label="NumberInput" defaultValue={42} />
</div>
</Card>
<Card id="cards" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Cards & Header blocks</h2>
<div className="grid gap-4 md:grid-cols-2">
<Card hover>
<CardHeader>
<CardTitle>Card title</CardTitle>
<CardDescription>Description secondaire</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted">Contenu principal de la card.</p>
</CardContent>
<CardFooter className="justify-end">
<Button size="sm" variant="outline">
Annuler
</Button>
<Button size="sm">Valider</Button>
</CardFooter>
</Card>
<Card className="p-4">
<h3 className="mb-3 font-medium text-foreground">Inline actions</h3>
<Input placeholder="Exemple inline" className="mb-2" />
<InlineFormActions
onCancel={() => {}}
onSubmit={() => {}}
isPending={false}
submitLabel="Ajouter"
/>
</Card>
</div>
</Card>
<Card id="avatars" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Avatar & Collaborators</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center gap-3">
<Avatar email="jane.doe@example.com" name="Jane Doe" size={40} />
<Avatar email="john.smith@example.com" name="John Smith" size={32} />
<Avatar email="team@example.com" size={24} />
</div>
<div className="space-y-3">
<CollaboratorDisplay
collaborator={{
raw: 'Jane Doe',
matchedUser: {
id: '1',
email: 'jane.doe@example.com',
name: 'Jane Doe',
},
}}
showEmail
/>
<CollaboratorDisplay
collaborator={{
raw: 'Intervenant externe',
matchedUser: null,
}}
/>
</div>
</div>
</Card>
<Card id="disclosure-dropdown" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Disclosure & Dropdown</h2>
<div className="space-y-4">
<Disclosure icon="" title="Panneau pliable" subtitle="Composant Disclosure">
<p className="text-sm text-muted">Contenu du panneau.</p>
</Disclosure>
<DropdownMenu
panelClassName="mt-2 w-56 rounded-lg border border-border bg-card p-2 shadow-lg"
trigger={({ open, toggle }) => (
<Button type="button" variant="outline" onClick={toggle}>
Menu demo {open ? '▲' : '▼'}
</Button>
)}
>
{({ close }) => (
<Button
size="sm"
variant="secondary"
onClick={() => {
setMenuCount((prev) => prev + 1);
close();
}}
>
Incrementer ({menuCount})
</Button>
)}
</DropdownMenu>
</div>
</Card>
<Card id="menu" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Menu</h2>
<DropdownMenu
panelClassName="mt-2 w-64 overflow-hidden rounded-lg border border-border bg-card py-1 shadow-lg"
trigger={({ open, toggle }) => (
<Button type="button" variant="outline" onClick={toggle}>
Ouvrir le menu {open ? '▲' : '▼'}
</Button>
)}
>
{({ close }) => (
<>
<div className="border-b border-border px-4 py-2">
<p className="text-xs text-muted">MENU DE DEMO</p>
<p className="text-sm font-medium text-foreground">Navigation rapide</p>
</div>
<button
type="button"
onClick={close}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
>
👤 Mon profil
</button>
<button
type="button"
onClick={close}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
>
👥 Equipes
</button>
<button
type="button"
onClick={close}
className="block w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
>
Se deconnecter
</button>
</>
)}
</DropdownMenu>
</Card>
<Card id="editable-titles" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Editable Titles</h2>
<div className="space-y-6">
<div>
<p className="mb-2 text-sm font-medium text-foreground">EditableTitle (base)</p>
<EditableTitle
sessionId="demo-editable-title"
initialTitle="Titre modifiable (cliquez pour tester)"
canEdit
onUpdate={async () => ({ success: true })}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<EditableSessionTitle
sessionId="demo-session-title"
initialTitle="Session title wrapper"
canEdit={false}
/>
<EditableMotivatorTitle
sessionId="demo-motivator-title"
initialTitle="Motivator title wrapper"
canEdit={false}
/>
<EditableYearReviewTitle
sessionId="demo-year-review-title"
initialTitle="Year review title wrapper"
canEdit={false}
/>
<EditableWeatherTitle
sessionId="demo-weather-title"
initialTitle="Weather title wrapper"
canEdit={false}
/>
<EditableWeeklyCheckInTitle
sessionId="demo-weekly-checkin-title"
initialTitle="Weekly check-in title wrapper"
canEdit={false}
/>
<EditableGifMoodTitle
sessionId="demo-gif-mood-title"
initialTitle="Gif mood title wrapper"
canEdit={false}
/>
</div>
</div>
</Card>
<Card id="session-header" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Session Header</h2>
<SessionPageHeader
workshopType="swot"
sessionId="demo-session"
sessionTitle="Atelier de demonstration"
isOwner={true}
canEdit={false}
ownerUser={{ name: 'Jane Doe', email: 'jane.doe@example.com' }}
date={new Date()}
collaborator={{
raw: 'Jane Doe',
matchedUser: {
id: '1',
email: 'jane.doe@example.com',
name: 'Jane Doe',
},
}}
badges={<Badge variant="primary">DEMO</Badge>}
/>
</Card>
<Card id="participant-input" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">ParticipantInput</h2>
<ParticipantInput name="participant" />
</Card>
<Card id="icons" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Icons</h2>
<div className="flex flex-wrap items-center gap-4 text-foreground">
<div className="flex items-center gap-2">
<IconEdit />
<span className="text-sm text-muted">Edit</span>
</div>
<div className="flex items-center gap-2">
<IconTrash />
<span className="text-sm text-muted">Trash</span>
</div>
<div className="flex items-center gap-2">
<IconDuplicate />
<span className="text-sm text-muted">Duplicate</span>
</div>
<div className="flex items-center gap-2">
<IconPlus />
<span className="text-sm text-muted">Plus</span>
</div>
<div className="flex items-center gap-2">
<IconCheck />
<span className="text-sm text-muted">Check</span>
</div>
<div className="flex items-center gap-2">
<IconClose />
<span className="text-sm text-muted">Close</span>
</div>
<div className="flex items-center gap-2">
<RocketIcon className="h-5 w-5" />
<span className="text-sm text-muted">Rocket</span>
</div>
</div>
</Card>
<Card id="modal" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Modal</h2>
<Button onClick={() => setModalOpen(true)}>Ouvrir la popup</Button>
</Card>
</div>
</div>
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title="Exemple de popup" size="md">
<p className="text-sm text-muted">
Ceci est un exemple de modal avec ses actions standardisees.
</p>
<ModalFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}>
Annuler
</Button>
<Button onClick={() => setModalOpen(false)}>Confirmer</Button>
</ModalFooter>
</Modal>
</main>
);
}

View File

@@ -0,0 +1,67 @@
import { notFound } from 'next/navigation';
import { auth } from '@/lib/auth';
import { getGifMoodSessionById } from '@/services/gif-mood';
import { getUserTeams } from '@/services/teams';
import { GifMoodBoard, GifMoodLiveWrapper } from '@/components/gif-mood';
import { Badge, SessionPageHeader } from '@/components/ui';
interface GifMoodSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function GifMoodSessionPage({ params }: GifMoodSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getGifMoodSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
const userTeams = await getUserTeams(authSession.user.id);
return (
<main className="mx-auto max-w-7xl px-4">
<SessionPageHeader
workshopType="gif-mood"
sessionId={session.id}
sessionTitle={session.title}
isOwner={session.isOwner}
canEdit={session.canEdit}
ownerUser={session.user}
date={session.date}
badges={<Badge variant="primary">{session.items.length} GIFs</Badge>}
/>
{/* Live Wrapper + Board */}
<GifMoodLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<GifMoodBoard
sessionId={session.id}
currentUserId={authSession.user.id}
items={session.items}
shares={session.shares}
owner={{
id: session.user.id,
name: session.user.name ?? null,
email: session.user.email ?? '',
}}
ratings={session.ratings}
canEdit={session.canEdit}
/>
</GifMoodLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
DateInput,
Input,
} from '@/components/ui';
import { createGifMoodSession } from '@/actions/gif-mood';
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
export default function NewGifMoodPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(
() =>
`GIF Mood - ${new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`
);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const date = selectedDate ? new Date(selectedDate) : undefined;
if (!title) {
setError('Veuillez remplir le titre');
setLoading(false);
return;
}
const result = await createGifMoodSession({ title, date });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/gif-mood/${result.data?.id}`);
}
return (
<main className="mx-auto max-w-2xl px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🎞</span>
Nouveau GIF Mood Board
</CardTitle>
<CardDescription>
Créez un tableau de bord GIF pour exprimer et partager votre humeur avec votre équipe
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre de la session"
name="title"
placeholder="Ex: GIF Mood - Mars 2026"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<DateInput
id="date"
name="date"
label="Date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
required
/>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>Partagez la session avec votre équipe</li>
<li>Chaque membre peut ajouter jusqu&apos;à {GIF_MOOD_MAX_ITEMS} GIFs</li>
<li>Ajoutez une note à chaque GIF pour expliquer votre humeur</li>
<li>Les GIFs apparaissent en temps réel pour tous les membres</li>
</ol>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer le GIF Mood Board
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -1,7 +1,8 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google'; import { Geist, Geist_Mono, Caveat } from 'next/font/google';
import './globals.css'; import './globals.css';
import { Providers } from '@/components/Providers'; import { Providers } from '@/components/Providers';
import { Header } from '@/components/layout/Header';
const geistSans = Geist({ const geistSans = Geist({
variable: '--font-geist-sans', variable: '--font-geist-sans',
@@ -13,6 +14,11 @@ const geistMono = Geist_Mono({
subsets: ['latin'], subsets: ['latin'],
}); });
const caveat = Caveat({
variable: '--font-caveat',
subsets: ['latin'],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Workshop Manager', title: 'Workshop Manager',
description: "Application de gestion d'ateliers pour entretiens managériaux", description: "Application de gestion d'ateliers pour entretiens managériaux",
@@ -36,8 +42,15 @@ export default function RootLayout({
}} }}
/> />
</head> </head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> <body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} antialiased`}>
<Providers>{children}</Providers> <Providers>
<div className="min-h-screen bg-background">
<Header />
<div className="py-6">
{children}
</div>
</div>
</Providers>
</body> </body>
</html> </html>
); );

View File

@@ -1,13 +1,10 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getMotivatorSessionById } from '@/services/moving-motivators'; import { getMotivatorSessionById } from '@/services/moving-motivators';
import { getUserTeams } from '@/services/teams'; import { getUserTeams } from '@/services/teams';
import type { ResolvedCollaborator } from '@/services/auth'; import type { ResolvedCollaborator } from '@/services/auth';
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators'; import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge, CollaboratorDisplay } from '@/components/ui'; import { Badge, SessionPageHeader } from '@/components/ui';
import { EditableMotivatorTitle } from '@/components/ui';
interface MotivatorSessionPageProps { interface MotivatorSessionPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -31,51 +28,22 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
} }
return ( return (
<main className="mx-auto max-w-7xl px-4 py-8"> <main className="mx-auto max-w-7xl px-4">
{/* Header */} <SessionPageHeader
<div className="mb-8"> workshopType="motivators"
<div className="flex items-center gap-2 text-sm text-muted mb-2"> sessionId={session.id}
<Link href={getSessionsTabUrl('motivators')} className="hover:text-foreground"> sessionTitle={session.title}
{getWorkshop('motivators').label} isOwner={session.isOwner}
</Link> canEdit={session.canEdit}
<span>/</span> ownerUser={session.user}
<span className="text-foreground">{session.title}</span> date={session.date}
{!session.isOwner && ( collaborator={session.resolvedParticipant as ResolvedCollaborator}
<Badge variant="accent" className="ml-2"> badges={
Partagé par {session.user.name || session.user.email} <Badge variant="primary">
</Badge> {session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
)} </Badge>
</div> }
/>
<div className="flex items-start justify-between">
<div>
<EditableMotivatorTitle
sessionId={session.id}
initialTitle={session.title}
canEdit={session.canEdit}
/>
<div className="mt-2">
<CollaboratorDisplay
collaborator={session.resolvedParticipant as ResolvedCollaborator}
size="lg"
showEmail
/>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">
{session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Live Wrapper + Board */} {/* Live Wrapper + Board */}
<MotivatorLiveWrapper <MotivatorLiveWrapper

View File

@@ -46,7 +46,7 @@ export default function NewMotivatorSessionPage() {
} }
return ( return (
<main className="mx-auto max-w-2xl px-4 py-8"> <main className="mx-auto max-w-2xl px-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">

View File

@@ -2,7 +2,7 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getUserOKRs } from '@/services/okrs'; import { getUserOKRs } from '@/services/okrs';
import { Card } from '@/components/ui'; import { Card, PageHeader, getButtonClassName } from '@/components/ui';
import { ObjectivesList } from '@/components/okrs/ObjectivesList'; import { ObjectivesList } from '@/components/okrs/ObjectivesList';
import { comparePeriods } from '@/lib/okr-utils'; import { comparePeriods } from '@/lib/okr-utils';
@@ -31,17 +31,12 @@ export default async function ObjectivesPage() {
const periods = Object.keys(okrsByPeriod).sort(comparePeriods); const periods = Object.keys(okrsByPeriod).sort(comparePeriods);
return ( return (
<main className="mx-auto max-w-7xl px-4 py-8"> <main className="mx-auto max-w-7xl px-4">
{/* Header */} <PageHeader
<div className="mb-8"> emoji="🎯"
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2"> title="Mes Objectifs"
<span className="text-3xl">🎯</span> subtitle="Suivez la progression de vos OKRs à travers toutes vos équipes"
Mes Objectifs />
</h1>
<p className="mt-2 text-muted">
Suivez la progression de vos OKRs à travers toutes vos équipes
</p>
</div>
{okrs.length === 0 ? ( {okrs.length === 0 ? (
<Card className="p-12 text-center"> <Card className="p-12 text-center">
@@ -51,10 +46,11 @@ export default async function ObjectivesPage() {
Vous n&apos;avez pas encore d&apos;OKR défini. Contactez un administrateur d&apos;équipe Vous n&apos;avez pas encore d&apos;OKR défini. Contactez un administrateur d&apos;équipe
pour en créer. pour en créer.
</p> </p>
<Link href="/teams"> <Link
<span className="inline-block rounded-lg bg-[var(--purple)] px-4 py-2 text-white hover:opacity-90"> href="/teams"
Voir mes équipes className={getButtonClassName({ variant: 'brand' })}
</span> >
Voir mes équipes
</Link> </Link>
</Card> </Card>
) : ( ) : (

View File

@@ -1,10 +1,11 @@
import Link from 'next/link'; import Link from 'next/link';
import { getButtonClassName } from '@/components/ui';
import { WORKSHOPS, getSessionsTabUrl } from '@/lib/workshops'; import { WORKSHOPS, getSessionsTabUrl } from '@/lib/workshops';
export default function Home() { export default function Home() {
return ( return (
<> <>
<main className="mx-auto max-w-7xl px-4 py-12"> <main className="mx-auto max-w-7xl px-4">
{/* Hero Section */} {/* Hero Section */}
<section className="mb-16 text-center"> <section className="mb-16 text-center">
<h1 className="mb-4 text-5xl font-bold text-foreground"> <h1 className="mb-4 text-5xl font-bold text-foreground">
@@ -282,11 +283,36 @@ export default function Home() {
Les 5 catégories du bilan Les 5 catégories du bilan
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<CategoryPill icon="🏆" name="Réalisations" color="#22c55e" description="Ce que vous avez accompli" /> <CategoryPill
<CategoryPill icon="⚔️" name="Défis" color="#ef4444" description="Les difficultés rencontrées" /> icon="🏆"
<CategoryPill icon="📚" name="Apprentissages" color="#3b82f6" description="Ce que vous avez appris" /> name="Réalisations"
<CategoryPill icon="🎯" name="Objectifs" color="#8b5cf6" description="Vos ambitions pour l'année prochaine" /> color="#22c55e"
<CategoryPill icon="⭐" name="Moments" color="#f59e0b" description="Les moments forts et marquants" /> description="Ce que vous avez accompli"
/>
<CategoryPill
icon="⚔️"
name="Défis"
color="#ef4444"
description="Les difficultés rencontrées"
/>
<CategoryPill
icon="📚"
name="Apprentissages"
color="#3b82f6"
description="Ce que vous avez appris"
/>
<CategoryPill
icon="🎯"
name="Objectifs"
color="#8b5cf6"
description="Vos ambitions pour l'année prochaine"
/>
<CategoryPill
icon="⭐"
name="Moments"
color="#f59e0b"
description="Les moments forts et marquants"
/>
</div> </div>
</div> </div>
@@ -328,7 +354,9 @@ export default function Home() {
<span className="text-4xl">📝</span> <span className="text-4xl">📝</span>
<div> <div>
<h2 className="text-3xl font-bold text-foreground">Weekly Check-in</h2> <h2 className="text-3xl font-bold text-foreground">Weekly Check-in</h2>
<p className="text-green-500 font-medium">Le point hebdomadaire avec vos collaborateurs</p> <p className="text-green-500 font-medium">
Le point hebdomadaire avec vos collaborateurs
</p>
</div> </div>
</div> </div>
@@ -340,9 +368,9 @@ export default function Home() {
Pourquoi faire un check-in hebdomadaire ? Pourquoi faire un check-in hebdomadaire ?
</h3> </h3>
<p className="text-muted mb-4"> <p className="text-muted mb-4">
Le Weekly Check-in est un rituel de management qui permet de maintenir un lien régulier Le Weekly Check-in est un rituel de management qui permet de maintenir un lien
avec vos collaborateurs. Il favorise la communication, l&apos;alignement et la détection régulier avec vos collaborateurs. Il favorise la communication, l&apos;alignement et
précoce des problèmes ou opportunités. la détection précoce des problèmes ou opportunités.
</p> </p>
<ul className="space-y-2 text-sm text-muted"> <ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
@@ -371,10 +399,30 @@ export default function Home() {
Les 4 catégories du check-in Les 4 catégories du check-in
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<CategoryPill icon="✅" name="Ce qui s'est bien passé" color="#22c55e" description="Les réussites et points positifs" /> <CategoryPill
<CategoryPill icon="⚠️" name="Ce qui s'est mal passé" color="#ef4444" description="Les difficultés et points d'amélioration" /> icon="✅"
<CategoryPill icon="🎯" name="Enjeux du moment" color="#3b82f6" description="Sur quoi je me concentre actuellement" /> name="Ce qui s'est bien passé"
<CategoryPill icon="🚀" name="Prochains enjeux" color="#8b5cf6" description="Ce sur quoi je vais me concentrer prochainement" /> color="#22c55e"
description="Les réussites et points positifs"
/>
<CategoryPill
icon="⚠️"
name="Ce qui s'est mal passé"
color="#ef4444"
description="Les difficultés et points d'amélioration"
/>
<CategoryPill
icon="🎯"
name="Enjeux du moment"
color="#3b82f6"
description="Sur quoi je me concentre actuellement"
/>
<CategoryPill
icon="🚀"
name="Prochains enjeux"
color="#8b5cf6"
description="Ce sur quoi je vais me concentrer prochainement"
/>
</div> </div>
</div> </div>
@@ -428,9 +476,9 @@ export default function Home() {
Pourquoi créer une météo personnelle ? Pourquoi créer une météo personnelle ?
</h3> </h3>
<p className="text-muted mb-4"> <p className="text-muted mb-4">
La météo est un outil simple et visuel pour exprimer rapidement votre état sur 4 axes clés. La météo est un outil simple et visuel pour exprimer rapidement votre état sur 4
En la partageant avec votre équipe, vous créez de la transparence et facilitez la communication axes clés. En la partageant avec votre équipe, vous créez de la transparence et
sur votre bien-être et votre performance. facilitez la communication sur votre bien-être et votre performance.
</p> </p>
<ul className="space-y-2 text-sm text-muted"> <ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
@@ -459,10 +507,30 @@ export default function Home() {
Les 4 axes de la météo Les 4 axes de la météo
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<CategoryPill icon="☀️" name="Performance" color="#f59e0b" description="Votre performance personnelle et l'atteinte de vos objectifs" /> <CategoryPill
<CategoryPill icon="😊" name="Moral" color="#22c55e" description="Votre moral actuel et votre ressenti" /> icon="☀️"
<CategoryPill icon="🌊" name="Flux" color="#3b82f6" description="Votre flux de travail personnel et les blocages éventuels" /> name="Performance"
<CategoryPill icon="💎" name="Création de valeur" color="#8b5cf6" description="Votre création de valeur et votre apport" /> color="#f59e0b"
description="Votre performance personnelle et l'atteinte de vos objectifs"
/>
<CategoryPill
icon="😊"
name="Moral"
color="#22c55e"
description="Votre moral actuel et votre ressenti"
/>
<CategoryPill
icon="🌊"
name="Flux"
color="#3b82f6"
description="Votre flux de travail personnel et les blocages éventuels"
/>
<CategoryPill
icon="💎"
name="Création de valeur"
color="#8b5cf6"
description="Votre création de valeur et votre apport"
/>
</div> </div>
</div> </div>
@@ -498,13 +566,126 @@ export default function Home() {
</div> </div>
</section> </section>
{/* GIF Mood Board Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">🎞</span>
<div>
<h2 className="text-3xl font-bold text-foreground">GIF Mood Board</h2>
<p className="font-medium" style={{ color: '#ec4899' }}>
Exprimez l&apos;humeur de l&apos;équipe en images
</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span>
Pourquoi un GIF Mood Board ?
</h3>
<p className="text-muted mb-4">
Les GIFs sont un langage universel pour exprimer ce que les mots peinent parfois à
traduire. Le GIF Mood Board transforme un rituel d&apos;équipe en moment visuel et
ludique, idéal pour les rétrospectives, les stand-ups ou tout point d&apos;équipe
récurrent.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Rendre les rétrospectives plus vivantes et engageantes
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Libérer l&apos;expression émotionnelle avec humour et créativité
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Voir en un coup d&apos;œil l&apos;humeur collective de l&apos;équipe
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Briser la glace et créer de la cohésion d&apos;équipe
</li>
</ul>
</div>
{/* What's in it */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Ce que chaque membre peut faire
</h3>
<div className="space-y-3">
<FeaturePill
icon="🎞️"
name="Jusqu'à 5 GIFs par session"
color="#ec4899"
description="Choisissez les GIFs qui reflètent le mieux votre humeur du moment"
/>
<FeaturePill
icon="✍️"
name="Notes manuscrites"
color="#8b5cf6"
description="Ajoutez un contexte ou une explication à chaque GIF"
/>
<FeaturePill
icon="⭐"
name="Note de la semaine sur 5"
color="#f59e0b"
description="Résumez votre semaine en une note globale visible par toute l'équipe"
/>
<FeaturePill
icon="⚡"
name="Mise à jour en temps réel"
color="#10b981"
description="Voir les GIFs des collègues apparaître au fur et à mesure"
/>
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Créer la session"
description="Le manager crée une session GIF Mood Board et la partage avec son équipe"
/>
<StepCard
number={2}
title="Choisir ses GIFs"
description="Chaque membre ajoute jusqu'à 5 GIFs (Giphy, Tenor, ou toute URL d'image) pour exprimer son humeur"
/>
<StepCard
number={3}
title="Annoter et noter"
description="Ajoutez une note manuscrite à chaque GIF et une note de semaine sur 5 étoiles"
/>
<StepCard
number={4}
title="Partager et discuter"
description="Parcourez le board ensemble, repérez les GIFs qui reflètent le mieux l'humeur de l'équipe et lancez la discussion"
/>
</div>
</div>
</div>
</section>
{/* OKRs Deep Dive Section */} {/* OKRs Deep Dive Section */}
<section className="mb-16"> <section className="mb-16">
<div className="flex items-center gap-3 mb-8"> <div className="flex items-center gap-3 mb-8">
<span className="text-4xl">🎯</span> <span className="text-4xl">🎯</span>
<div> <div>
<h2 className="text-3xl font-bold text-foreground">OKRs & Équipes</h2> <h2 className="text-3xl font-bold text-foreground">OKRs & Équipes</h2>
<p className="text-purple-500 font-medium">Définissez et suivez les objectifs de votre équipe</p> <p className="text-purple-500 font-medium">
Définissez et suivez les objectifs de votre équipe
</p>
</div> </div>
</div> </div>
@@ -516,9 +697,10 @@ export default function Home() {
Pourquoi utiliser les OKRs ? Pourquoi utiliser les OKRs ?
</h3> </h3>
<p className="text-muted mb-4"> <p className="text-muted mb-4">
Les OKRs (Objectives and Key Results) sont un cadre de gestion d&apos;objectifs qui permet Les OKRs (Objectives and Key Results) sont un cadre de gestion d&apos;objectifs qui
d&apos;aligner les efforts de l&apos;équipe autour d&apos;objectifs communs et mesurables. permet d&apos;aligner les efforts de l&apos;équipe autour d&apos;objectifs communs
Cette méthode favorise la transparence, la responsabilisation et la performance collective. et mesurables. Cette méthode favorise la transparence, la responsabilisation et la
performance collective.
</p> </p>
<ul className="space-y-2 text-sm text-muted"> <ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
@@ -547,29 +729,29 @@ export default function Home() {
Fonctionnalités principales Fonctionnalités principales
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<FeaturePill <FeaturePill
icon="👥" icon="👥"
name="Gestion d'équipes" name="Gestion d'équipes"
color="#8b5cf6" color="#8b5cf6"
description="Créez des équipes et gérez les membres avec des rôles admin/membre" description="Créez des équipes et gérez les membres avec des rôles admin/membre"
/> />
<FeaturePill <FeaturePill
icon="🎯" icon="🎯"
name="OKRs par période" name="OKRs par période"
color="#3b82f6" color="#3b82f6"
description="Définissez des OKRs pour des trimestres ou périodes personnalisées" description="Définissez des OKRs pour des trimestres ou périodes personnalisées"
/> />
<FeaturePill <FeaturePill
icon="📊" icon="📊"
name="Key Results mesurables" name="Key Results mesurables"
color="#10b981" color="#10b981"
description="Suivez la progression de chaque Key Result avec des valeurs et pourcentages" description="Suivez la progression de chaque Key Result avec des valeurs et pourcentages"
/> />
<FeaturePill <FeaturePill
icon="👁️" icon="👁️"
name="Visibilité transparente" name="Visibilité transparente"
color="#f59e0b" color="#f59e0b"
description="Tous les membres de l'équipe peuvent voir les OKRs de chacun" description="Tous les membres de l'équipe peuvent voir les OKRs de chacun"
/> />
</div> </div>
</div> </div>
@@ -707,14 +889,16 @@ function WorkshopCard({
<div className="flex gap-3"> <div className="flex gap-3">
<Link <Link
href={newHref} href={newHref}
className="flex-1 rounded-lg px-4 py-2.5 text-center font-medium text-white transition-colors" className={getButtonClassName({
className: 'flex-1 border-transparent text-white',
})}
style={{ backgroundColor: accentColor }} style={{ backgroundColor: accentColor }}
> >
Démarrer Démarrer
</Link> </Link>
<Link <Link
href={href} href={href}
className="rounded-lg border border-border px-4 py-2.5 font-medium text-foreground transition-colors hover:bg-card-hover" className={getButtonClassName({ variant: 'outline' })}
> >
Mes sessions Mes sessions
</Link> </Link>

View File

@@ -39,29 +39,20 @@ export function PasswordForm() {
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <Input
<label id="currentPassword"
htmlFor="currentPassword" type="password"
className="mb-1.5 block text-sm font-medium text-foreground" label="Mot de passe actuel"
> value={currentPassword}
Mot de passe actuel onChange={(e) => setCurrentPassword(e.target.value)}
</label> required
<Input />
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div>
<div> <div>
<label htmlFor="newPassword" className="mb-1.5 block text-sm font-medium text-foreground">
Nouveau mot de passe
</label>
<Input <Input
id="newPassword" id="newPassword"
type="password" type="password"
label="Nouveau mot de passe"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
required required
@@ -71,15 +62,10 @@ export function PasswordForm() {
</div> </div>
<div> <div>
<label
htmlFor="confirmPassword"
className="mb-1.5 block text-sm font-medium text-foreground"
>
Confirmer le nouveau mot de passe
</label>
<Input <Input
id="confirmPassword" id="confirmPassword"
type="password" type="password"
label="Confirmer le nouveau mot de passe"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
required required

View File

@@ -39,31 +39,23 @@ export function ProfileForm({ initialData }: ProfileFormProps) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <Input
<label htmlFor="name" className="mb-1.5 block text-sm font-medium text-foreground"> id="name"
Nom type="text"
</label> label="Nom"
<Input value={name}
id="name" onChange={(e) => setName(e.target.value)}
type="text" placeholder="Votre nom"
value={name} />
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
/>
</div>
<div> <Input
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground"> id="email"
Email type="email"
</label> label="Email"
<Input value={email}
id="email" onChange={(e) => setEmail(e.target.value)}
type="email" required
value={email} />
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{message && ( {message && (
<p <p

View File

@@ -19,18 +19,19 @@ export default async function ProfilePage() {
} }
return ( return (
<main className="mx-auto max-w-2xl px-4 py-8"> <main className="mx-auto max-w-2xl px-4">
<div className="mb-8 flex items-center gap-6"> <div className="mb-8 flex items-center gap-5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={getGravatarUrl(user.email, 160)} src={getGravatarUrl(user.email, 160)}
alt={user.name || user.email} alt={user.name || user.email}
width={80} width={72}
height={80} height={72}
className="rounded-full border-2 border-border" className="rounded-full border-2 border-border shrink-0"
/> />
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Mon Profil</h1> <h1 className="text-3xl font-bold tracking-tight text-foreground">Mon Profil</h1>
<p className="mt-1 text-muted">Gérez vos informations personnelles</p> <p className="mt-1.5 text-sm text-muted">Gérez vos informations personnelles</p>
</div> </div>
</div> </div>

View File

@@ -1,53 +1,53 @@
'use client'; 'use client';
import { useState, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui'; import { Button, DropdownMenu } from '@/components/ui';
import { WORKSHOPS } from '@/lib/workshops'; import { WORKSHOPS } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
export function NewWorkshopDropdown() { export function NewWorkshopDropdown() {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useClickOutside(containerRef, () => setOpen(false), open);
return ( return (
<div ref={containerRef} className="relative"> <DropdownMenu
<Button panelClassName="absolute right-0 z-20 mt-2 w-60 rounded-xl border border-border bg-card py-1.5 shadow-lg"
type="button" trigger={({ open, toggle }) => (
variant="outline" <Button type="button" variant="primary" size="sm" onClick={toggle} className="gap-1.5">
size="sm" <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
onClick={() => setOpen(!open)} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
className="gap-1.5" </svg>
> Nouvel atelier
Nouvel atelier <svg
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} fill="none"
fill="none" viewBox="0 0 24 24"
viewBox="0 0 24 24" stroke="currentColor"
stroke="currentColor" >
> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> </svg>
</svg> </Button>
</Button> )}
{open && ( >
<div className="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg"> {({ close }) => (
<>
{WORKSHOPS.map((w) => ( {WORKSHOPS.map((w) => (
<Link <Link
key={w.id} key={w.id}
href={w.newPath} href={w.newPath}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover" className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover transition-colors"
onClick={() => setOpen(false)} onClick={close}
> >
<span className="text-lg">{w.icon}</span> <span
className="flex h-8 w-8 items-center justify-center rounded-lg text-base flex-shrink-0"
style={{ backgroundColor: `${w.accentColor}18` }}
>
{w.icon}
</span>
<div> <div>
<div className="font-medium">{w.label}</div> <div className="font-medium">{w.label}</div>
<div className="text-xs text-muted">{w.description}</div> <div className="text-xs text-muted">{w.description}</div>
</div> </div>
</Link> </Link>
))} ))}
</div> </>
)} )}
</div> </DropdownMenu>
); );
} }

View File

@@ -0,0 +1,283 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import {
Button,
Modal,
ModalFooter,
Input,
CollaboratorDisplay,
IconButton,
IconEdit,
IconTrash,
} from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather';
import { deleteGifMoodSession, updateGifMoodSession } from '@/actions/gif-mood';
import { type WorkshopTypeId, getWorkshop, getSessionPath } from '@/lib/workshops';
import type { Share } from '@/lib/share-utils';
import type {
AnySession, CardView,
SwotSession, MotivatorSession, YearReviewSession,
WeeklyCheckInSession, WeatherSession, GifMoodSession,
} from './workshop-session-types';
import { TABLE_COLS } from './workshop-session-types';
import { getResolvedCollaborator, formatDate, getStatsText } from './workshop-session-helpers';
// ─── RoleBadge ────────────────────────────────────────────────────────────────
export function RoleBadge({ role }: { role: 'OWNER' | 'VIEWER' | 'EDITOR' }) {
return (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0"
style={{
backgroundColor: role === 'EDITOR' ? 'rgba(6,182,212,0.12)' : 'rgba(234,179,8,0.12)',
color: role === 'EDITOR' ? '#06b6d4' : '#ca8a04',
}}
>
{role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</span>
);
}
// ─── SharesList ───────────────────────────────────────────────────────────────
export function SharesList({ shares }: { shares: Share[] }) {
if (!shares.length) return null;
return (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] text-muted">Partagé avec</span>
{shares.slice(0, 3).map((s) => (
<span key={s.id} className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{s.user.name?.split(' ')[0] || s.user.email.split('@')[0]}
</span>
))}
{shares.length > 3 && <span className="text-[10px] text-muted">+{shares.length - 3}</span>}
</div>
);
}
// ─── SessionCard ──────────────────────────────────────────────────────────────
export function SessionCard({
session, isTeamCollab = false, view = 'grid',
}: {
session: AnySession; isTeamCollab?: boolean; view?: CardView;
}) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition();
const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const isGifMood = session.workshopType === 'gif-mood';
const participant = isSwot ? (session as SwotSession).collaborator
: isYearReview ? (session as YearReviewSession).participant
: isWeeklyCheckIn ? (session as WeeklyCheckInSession).participant
: isWeather ? (session as WeatherSession).user.name || (session as WeatherSession).user.email
: isGifMood ? (session as GifMoodSession).user.name || (session as GifMoodSession).user.email
: (session as MotivatorSession).participant;
const [editTitle, setEditTitle] = useState(session.title);
const [editParticipant, setEditParticipant] = useState(
isSwot ? (session as SwotSession).collaborator
: isYearReview ? (session as YearReviewSession).participant
: isWeather || isGifMood ? ''
: (session as MotivatorSession).participant
);
const workshop = getWorkshop(session.workshopType as WorkshopTypeId);
const href = getSessionPath(session.workshopType as WorkshopTypeId, session.id);
const accentColor = workshop.accentColor;
const resolved = getResolvedCollaborator(session);
const participantName = resolved.matchedUser?.name || resolved.matchedUser?.email?.split('@')[0] || resolved.raw;
const statsText = getStatsText(session);
const handleDelete = () => {
startTransition(async () => {
const result = isSwot ? await deleteSwotSession(session.id)
: isYearReview ? await deleteYearReviewSession(session.id)
: isWeeklyCheckIn ? await deleteWeeklyCheckInSession(session.id)
: isWeather ? await deleteWeatherSession(session.id)
: isGifMood ? await deleteGifMoodSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) setShowDeleteModal(false);
else console.error('Error deleting:', result.error);
});
};
const handleEdit = () => {
startTransition(async () => {
const result = isSwot ? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: isYearReview ? await updateYearReviewSession(session.id, { title: editTitle, participant: editParticipant })
: isWeeklyCheckIn ? await updateWeeklyCheckInSession(session.id, { title: editTitle, participant: editParticipant })
: isWeather ? await updateWeatherSession(session.id, { title: editTitle })
: isGifMood ? await updateGifMoodSession(session.id, { title: editTitle })
: await updateMotivatorSession(session.id, { title: editTitle, participant: editParticipant });
if (result.success) setShowEditModal(false);
else console.error('Error updating:', result.error);
});
};
const openEditModal = () => { setEditTitle(session.title); setEditParticipant(participant); setShowEditModal(true); };
const hoverCard = !isTeamCollab ? 'hover:-translate-y-0.5 hover:shadow-md' : '';
const opacity = isTeamCollab ? 'opacity-60' : '';
// ── Vue Grille ───────────────────────────────────────────────────────────
const gridCard = (
<div className={`h-full flex rounded-xl bg-card border border-border overflow-hidden transition-all duration-200 ${hoverCard} ${opacity}`}>
<div className="w-1 flex-shrink-0" style={{ backgroundColor: accentColor }} />
<div className="flex flex-col flex-1 px-4 py-4 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-foreground line-clamp-2 leading-snug text-[15px] flex-1">{session.title}</h3>
{!session.isOwner && <RoleBadge role={session.role} />}
</div>
<CollaboratorDisplay collaborator={resolved} size="sm" />
{!session.isOwner && <p className="text-xs text-muted mt-0.5 truncate">par {session.user.name || session.user.email}</p>}
{session.isOwner && session.shares.length > 0 && <div className="mt-2"><SharesList shares={session.shares} /></div>}
<div className="flex-1 min-h-4" />
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border text-xs text-muted">
<div className="flex items-center gap-1.5 min-w-0 truncate">
<span className="text-sm leading-none flex-shrink-0">{workshop.icon}</span>
<span className="font-medium flex-shrink-0" style={{ color: accentColor }}>{workshop.labelShort}</span>
<span className="opacity-30 flex-shrink-0">·</span>
<span className="truncate">{statsText}</span>
</div>
<span className="text-[11px] whitespace-nowrap ml-3 flex-shrink-0">{formatDate(session.updatedAt)}</span>
</div>
</div>
</div>
);
// ── Vue Liste ────────────────────────────────────────────────────────────
const listCard = (
<div className={`flex items-center gap-3 rounded-xl bg-card border border-border overflow-hidden transition-all duration-150 ${!isTeamCollab ? 'hover:shadow-sm' : ''} ${opacity} px-4 py-3`}>
<div className="w-0.5 self-stretch rounded-full flex-shrink-0" style={{ backgroundColor: accentColor }} />
<span className="text-lg leading-none flex-shrink-0">{workshop.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-semibold text-foreground text-sm truncate">{session.title}</span>
{!session.isOwner && <RoleBadge role={session.role} />}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted flex-wrap">
<span className="truncate max-w-[140px]">{participantName}</span>
<span className="opacity-30">·</span>
<span className="font-medium flex-shrink-0" style={{ color: accentColor }}>{workshop.labelShort}</span>
<span className="opacity-30">·</span>
<span className="whitespace-nowrap">{statsText}</span>
</div>
</div>
<span className="text-[11px] text-muted whitespace-nowrap flex-shrink-0 ml-2">{formatDate(session.updatedAt)}</span>
<svg className="w-3.5 h-3.5 text-muted/40 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
);
// ── Vue Tableau ──────────────────────────────────────────────────────────
const tableRow = (
<div
className={`grid border-b border-border last:border-0 transition-colors ${!isTeamCollab ? 'hover:bg-card-hover/60' : ''} ${opacity}`}
style={{ gridTemplateColumns: TABLE_COLS }}
>
<div className="px-4 py-3 flex items-center gap-2">
<div className="w-0.5 h-5 rounded-full flex-shrink-0" style={{ backgroundColor: accentColor }} />
<span className="text-base leading-none">{workshop.icon}</span>
<span className="text-xs font-semibold truncate" style={{ color: accentColor }}>{workshop.labelShort}</span>
</div>
<div className="px-4 py-3 flex items-center gap-2 min-w-0">
<span className="font-medium text-foreground text-sm truncate">{session.title}</span>
{!session.isOwner && <RoleBadge role={session.role} />}
</div>
<div className="px-4 py-3 flex items-center">
<CollaboratorDisplay
collaborator={{ raw: session.user.name || session.user.email, matchedUser: { id: session.user.id, email: session.user.email, name: session.user.name } }}
size="sm"
/>
</div>
<div className="px-4 py-3 flex items-center">
<CollaboratorDisplay collaborator={resolved} size="sm" />
</div>
<div className="px-4 py-3 flex items-center text-xs text-muted">
<span className="truncate">{statsText}</span>
</div>
<div className="px-4 py-3 flex items-center text-xs text-muted whitespace-nowrap">
{formatDate(session.updatedAt)}
</div>
</div>
);
const cardContent = view === 'list' ? listCard : view === 'table' ? tableRow : gridCard;
const actionButtons = (
<>
{(session.isOwner || session.role === 'EDITOR' || session.isTeamCollab) && (
<div className={`absolute flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-20 ${view === 'table' ? 'top-1/2 -translate-y-1/2 right-3' : 'top-2.5 right-2.5'}`}>
<IconButton
onClick={(e) => { e.preventDefault(); e.stopPropagation(); openEditModal(); }}
label="Modifier"
icon={<IconEdit />}
variant="primary"
className="bg-card shadow-sm"
/>
{(session.isOwner || session.isTeamCollab) && (
<IconButton
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setShowDeleteModal(true); }}
label="Supprimer"
icon={<IconTrash />}
variant="destructive"
className="bg-card shadow-sm"
/>
)}
</div>
)}
</>
);
return (
<>
<div className="relative group">
<Link href={href} className={view === 'table' ? 'block' : undefined} title={isTeamCollab ? "Atelier de l'équipe" : undefined}>
{cardContent}
</Link>
{actionButtons}
</div>
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Modifier l'atelier" size="sm">
<form onSubmit={(e) => { e.preventDefault(); handleEdit(); }} className="space-y-4">
<Input id="edit-title" label="Titre" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} placeholder="Titre de l'atelier" required />
<div>
{!isWeather && !isGifMood && (
<Input id="edit-participant" label={workshop.participantLabel} value={editParticipant} onChange={(e) => setEditParticipant(e.target.value)}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'} required />
)}
</div>
<ModalFooter>
<Button type="button" variant="outline" onClick={() => setShowEditModal(false)} disabled={isPending}>Annuler</Button>
<Button type="submit" disabled={isPending || !editTitle.trim() || (!isWeather && !isGifMood && !editParticipant.trim())}>
{isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button>
</ModalFooter>
</form>
</Modal>
<Modal isOpen={showDeleteModal} onClose={() => setShowDeleteModal(false)} title="Supprimer l'atelier" size="sm">
<div className="space-y-4">
<p className="text-muted">Êtes-vous sûr de vouloir supprimer <strong className="text-foreground">&quot;{session.title}&quot;</strong> ?</p>
<p className="text-sm text-destructive">Cette action est irréversible. Toutes les données seront perdues.</p>
<ModalFooter>
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={isPending}>Annuler</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>{isPending ? 'Suppression...' : 'Supprimer'}</Button>
</ModalFooter>
</div>
</Modal>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,10 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getSessionById } from '@/services/sessions'; import { getSessionById } from '@/services/sessions';
import { getUserTeams } from '@/services/teams'; import { getUserTeams } from '@/services/teams';
import { SwotBoard } from '@/components/swot/SwotBoard'; import { SwotBoard } from '@/components/swot/SwotBoard';
import { SessionLiveWrapper } from '@/components/collaboration'; import { SessionLiveWrapper } from '@/components/collaboration';
import { EditableSessionTitle } from '@/components/ui'; import { Badge, SessionPageHeader } from '@/components/ui';
import { Badge, CollaboratorDisplay } from '@/components/ui';
interface SessionPageProps { interface SessionPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -31,50 +28,21 @@ export default async function SessionPage({ params }: SessionPageProps) {
} }
return ( return (
<main className="mx-auto max-w-7xl px-4 py-8"> <main className="mx-auto max-w-7xl px-4">
{/* Header */} <SessionPageHeader
<div className="mb-8"> workshopType="swot"
<div className="flex items-center gap-2 text-sm text-muted mb-2"> sessionId={session.id}
<Link href={getSessionsTabUrl('swot')} className="hover:text-foreground"> sessionTitle={session.title}
{getWorkshop('swot').labelShort} isOwner={session.isOwner}
</Link> canEdit={session.canEdit}
<span>/</span> ownerUser={session.user}
<span className="text-foreground">{session.title}</span> date={session.date}
{!session.isOwner && ( collaborator={session.resolvedCollaborator}
<Badge variant="accent" className="ml-2"> badges={<>
Partagé par {session.user.name || session.user.email} <Badge variant="primary">{session.items.length} items</Badge>
</Badge> <Badge variant="success">{session.actions.length} actions</Badge>
)} </>}
</div> />
<div className="flex items-start justify-between">
<div>
<EditableSessionTitle
sessionId={session.id}
initialTitle={session.title}
canEdit={session.canEdit}
/>
<div className="mt-2">
<CollaboratorDisplay
collaborator={session.resolvedCollaborator}
size="lg"
showEmail
/>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
<Badge variant="success">{session.actions.length} actions</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Live Session Wrapper */} {/* Live Session Wrapper */}
<SessionLiveWrapper <SessionLiveWrapper

View File

@@ -0,0 +1,32 @@
export default function SessionsLoading() {
return (
<main className="mx-auto max-w-7xl px-4">
{/* PageHeader skeleton */}
<div className="py-6 flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-card rounded-xl animate-pulse" />
<div className="space-y-2">
<div className="h-7 w-40 bg-card rounded animate-pulse" />
<div className="h-4 w-64 bg-card rounded animate-pulse" />
</div>
</div>
<div className="h-9 w-36 bg-card rounded-lg animate-pulse" />
</div>
<div className="space-y-6">
{/* Tabs skeleton */}
<div className="flex gap-2 pb-2">
{[120, 100, 110, 90, 105].map((w, i) => (
<div key={i} className="h-9 bg-card animate-pulse rounded-full" style={{ width: w }} />
))}
</div>
{/* Cards grid skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-44 bg-card animate-pulse rounded-xl border border-border" />
))}
</div>
</div>
</main>
);
}

View File

@@ -56,7 +56,7 @@ export default function NewSessionPage() {
} }
return ( return (
<main className="mx-auto max-w-2xl px-4 py-8"> <main className="mx-auto max-w-2xl px-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">

View File

@@ -20,8 +20,13 @@ import {
getWeatherSessionsByUserId, getWeatherSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions, getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions,
} from '@/services/weather'; } from '@/services/weather';
import { Card } from '@/components/ui'; import {
getGifMoodSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamGifMoodSessions,
} from '@/services/gif-mood';
import { Card, PageHeader } from '@/components/ui';
import { withWorkshopType } from '@/lib/workshops'; import { withWorkshopType } from '@/lib/workshops';
import { SESSIONS_PAGE_SIZE } from '@/lib/types';
import { WorkshopTabs } from './WorkshopTabs'; import { WorkshopTabs } from './WorkshopTabs';
import { NewWorkshopDropdown } from './NewWorkshopDropdown'; import { NewWorkshopDropdown } from './NewWorkshopDropdown';
@@ -29,15 +34,15 @@ function WorkshopTabsSkeleton() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Tabs skeleton */} {/* Tabs skeleton */}
<div className="flex gap-2 border-b border-border pb-4"> <div className="flex gap-2 pb-2">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<div key={i} className="h-10 w-32 bg-card animate-pulse rounded-lg" /> <div key={i} className="h-9 w-28 bg-card animate-pulse rounded-full" />
))} ))}
</div> </div>
{/* Cards skeleton */} {/* Cards skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => ( {[...Array(6)].map((_, i) => (
<div key={i} className="h-40 bg-card animate-pulse rounded-xl" /> <div key={i} className="h-44 bg-card animate-pulse rounded-xl" />
))} ))}
</div> </div>
</div> </div>
@@ -58,36 +63,52 @@ export default async function SessionsPage() {
yearReviewSessions, yearReviewSessions,
weeklyCheckInSessions, weeklyCheckInSessions,
weatherSessions, weatherSessions,
gifMoodSessions,
teamSwotSessions, teamSwotSessions,
teamMotivatorSessions, teamMotivatorSessions,
teamYearReviewSessions, teamYearReviewSessions,
teamWeeklyCheckInSessions, teamWeeklyCheckInSessions,
teamWeatherSessions, teamWeatherSessions,
teamGifMoodSessions,
] = await Promise.all([ ] = await Promise.all([
getSessionsByUserId(session.user.id), getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id), getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id), getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id), getWeeklyCheckInSessionsByUserId(session.user.id),
getWeatherSessionsByUserId(session.user.id), getWeatherSessionsByUserId(session.user.id),
getGifMoodSessionsByUserId(session.user.id),
getTeamSwotSessions(session.user.id), getTeamSwotSessions(session.user.id),
getTeamMotivatorSessions(session.user.id), getTeamMotivatorSessions(session.user.id),
getTeamYearReviewSessions(session.user.id), getTeamYearReviewSessions(session.user.id),
getTeamWeeklyCheckInSessions(session.user.id), getTeamWeeklyCheckInSessions(session.user.id),
getTeamWeatherSessions(session.user.id), getTeamWeatherSessions(session.user.id),
getTeamGifMoodSessions(session.user.id),
]); ]);
// Add workshopType to each session for unified display // Track totals before slicing for pagination UI
const allSwotSessions = withWorkshopType(swotSessions, 'swot'); const totals = {
const allMotivatorSessions = withWorkshopType(motivatorSessions, 'motivators'); swot: swotSessions.length,
const allYearReviewSessions = withWorkshopType(yearReviewSessions, 'year-review'); motivators: motivatorSessions.length,
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin'); 'year-review': yearReviewSessions.length,
const allWeatherSessions = withWorkshopType(weatherSessions, 'weather'); 'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length,
'gif-mood': gifMoodSessions.length,
};
// Add workshopType and slice first page
const allSwotSessions = withWorkshopType(swotSessions.slice(0, SESSIONS_PAGE_SIZE), 'swot');
const allMotivatorSessions = withWorkshopType(motivatorSessions.slice(0, SESSIONS_PAGE_SIZE), 'motivators');
const allYearReviewSessions = withWorkshopType(yearReviewSessions.slice(0, SESSIONS_PAGE_SIZE), 'year-review');
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions.slice(0, SESSIONS_PAGE_SIZE), 'weekly-checkin');
const allWeatherSessions = withWorkshopType(weatherSessions.slice(0, SESSIONS_PAGE_SIZE), 'weather');
const allGifMoodSessions = withWorkshopType(gifMoodSessions.slice(0, SESSIONS_PAGE_SIZE), 'gif-mood');
const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot'); const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot');
const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators'); const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review'); const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review');
const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin'); const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin');
const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather'); const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather');
const teamGifMoodWithType = withWorkshopType(teamGifMoodSessions, 'gif-mood');
// Combine and sort by updatedAt // Combine and sort by updatedAt
const allSessions = [ const allSessions = [
@@ -96,20 +117,24 @@ export default async function SessionsPage() {
...allYearReviewSessions, ...allYearReviewSessions,
...allWeeklyCheckInSessions, ...allWeeklyCheckInSessions,
...allWeatherSessions, ...allWeatherSessions,
...allGifMoodSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0; const hasNoSessions = allSessions.length === 0;
const totalCount = allSessions.length;
return ( return (
<main className="mx-auto max-w-7xl px-4 py-8"> <main className="mx-auto max-w-7xl px-4">
{/* Header */} <PageHeader
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> emoji="🗂️"
<div> title="Mes Ateliers"
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1> subtitle={
<p className="mt-1 text-muted">Tous vos ateliers en un seul endroit</p> totalCount > 0
</div> ? `${totalCount} atelier${totalCount > 1 ? 's' : ''} · Tous vos ateliers en un seul endroit`
<NewWorkshopDropdown /> : 'Tous vos ateliers en un seul endroit'
</div> }
actions={<NewWorkshopDropdown />}
/>
{/* Content */} {/* Content */}
{hasNoSessions ? ( {hasNoSessions ? (
@@ -135,12 +160,15 @@ export default async function SessionsPage() {
yearReviewSessions={allYearReviewSessions} yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions} weeklyCheckInSessions={allWeeklyCheckInSessions}
weatherSessions={allWeatherSessions} weatherSessions={allWeatherSessions}
gifMoodSessions={allGifMoodSessions}
totals={totals}
teamCollabSessions={[ teamCollabSessions={[
...teamSwotWithType, ...teamSwotWithType,
...teamMotivatorWithType, ...teamMotivatorWithType,
...teamYearReviewWithType, ...teamYearReviewWithType,
...teamWeeklyCheckInWithType, ...teamWeeklyCheckInWithType,
...teamWeatherWithType, ...teamWeatherWithType,
...teamGifMoodWithType,
]} ]}
/> />
</Suspense> </Suspense>

View File

@@ -0,0 +1,93 @@
import type {
AnySession, SortCol, ResolvedCollaborator,
SwotSession, MotivatorSession, YearReviewSession,
WeeklyCheckInSession, WeatherSession, GifMoodSession,
} from './workshop-session-types';
export function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
if (session.workshopType === 'swot') return (session as SwotSession).resolvedCollaborator;
if (session.workshopType === 'year-review') return (session as YearReviewSession).resolvedParticipant;
if (session.workshopType === 'weekly-checkin') return (session as WeeklyCheckInSession).resolvedParticipant;
if (session.workshopType === 'weather') {
const s = session as WeatherSession;
return { raw: s.user.name || s.user.email, matchedUser: { id: s.user.id, email: s.user.email, name: s.user.name } };
}
if (session.workshopType === 'gif-mood') {
const s = session as GifMoodSession;
return { raw: s.user.name || s.user.email, matchedUser: { id: s.user.id, email: s.user.email, name: s.user.name } };
}
return (session as MotivatorSession).resolvedParticipant;
}
export function getGroupKey(session: AnySession): string {
const r = getResolvedCollaborator(session);
return r.matchedUser ? `user:${r.matchedUser.id}` : `raw:${r.raw.trim().toLowerCase()}`;
}
export function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
const grouped = new Map<string, AnySession[]>();
sessions.forEach((s) => {
const key = getGroupKey(s);
const existing = grouped.get(key);
if (existing) existing.push(s);
else grouped.set(key, [s]);
});
grouped.forEach((arr) => arr.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
return grouped;
}
export function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
export function getMonthGroup(date: Date | string): string {
return new Date(date).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
}
export function getStatsText(session: AnySession): string {
const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const isGifMood = session.workshopType === 'gif-mood';
if (isSwot) return `${(session as SwotSession)._count.items} items · ${(session as SwotSession)._count.actions} actions`;
if (isYearReview) return `${(session as YearReviewSession)._count.items} items · ${(session as YearReviewSession).year}`;
if (isWeeklyCheckIn) return `${(session as WeeklyCheckInSession)._count.items} items · ${formatDate((session as WeeklyCheckInSession).date)}`;
if (isWeather) return `${(session as WeatherSession)._count.entries} membres · ${formatDate((session as WeatherSession).date)}`;
if (isGifMood) return `${(session as GifMoodSession)._count.items} GIFs · ${formatDate((session as GifMoodSession).date)}`;
return `${(session as MotivatorSession)._count.cards}/10 motivateurs`;
}
function getStatsSortValue(session: AnySession): number {
if (session.workshopType === 'swot') return (session as SwotSession)._count.items;
if (session.workshopType === 'year-review') return (session as YearReviewSession)._count.items;
if (session.workshopType === 'weekly-checkin') return (session as WeeklyCheckInSession)._count.items;
if (session.workshopType === 'weather') return (session as WeatherSession)._count.entries;
if (session.workshopType === 'gif-mood') return (session as GifMoodSession)._count.items;
return (session as MotivatorSession)._count.cards;
}
function getParticipantSortName(session: AnySession): string {
const r = getResolvedCollaborator(session);
return (r.matchedUser?.name || r.matchedUser?.email?.split('@')[0] || r.raw).toLowerCase();
}
function getCreatorName(session: AnySession): string {
return (session.user.name || session.user.email).toLowerCase();
}
export function sortSessions(sessions: AnySession[], col: SortCol, dir: 'asc' | 'desc'): AnySession[] {
return [...sessions].sort((a, b) => {
let cmp = 0;
switch (col) {
case 'type': cmp = a.workshopType.localeCompare(b.workshopType); break;
case 'titre': cmp = a.title.localeCompare(b.title, 'fr'); break;
case 'createur': cmp = getCreatorName(a).localeCompare(getCreatorName(b), 'fr'); break;
case 'participant': cmp = getParticipantSortName(a).localeCompare(getParticipantSortName(b), 'fr'); break;
case 'stats': cmp = getStatsSortValue(a) - getStatsSortValue(b); break;
case 'date': cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); break;
}
return dir === 'asc' ? cmp : -cmp;
});
}

View File

@@ -0,0 +1,104 @@
import { WORKSHOPS } from '@/lib/workshops';
import type { Share } from '@/lib/share-utils';
export type CardView = 'grid' | 'list' | 'table' | 'timeline';
export type SortCol = 'type' | 'titre' | 'createur' | 'participant' | 'stats' | 'date';
// Colonnes tableau : type | titre | créateur | participant | stats | date
export const TABLE_COLS = '160px 1fr 160px 160px 160px 76px';
export const SORT_COLUMNS: { key: SortCol; label: string }[] = [
{ key: 'type', label: 'Type' },
{ key: 'titre', label: 'Titre' },
{ key: 'createur', label: 'Créateur' },
{ key: 'participant', label: 'Participant' },
{ key: 'stats', label: 'Stats' },
{ key: 'date', label: 'Date' },
];
export const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' },
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
];
export interface ResolvedCollaborator {
raw: string;
matchedUser: { id: string; email: string; name: string | null } | null;
}
export interface SwotSession {
id: string; title: string; collaborator: string;
resolvedCollaborator: ResolvedCollaborator; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { items: number; actions: number };
workshopType: 'swot'; isTeamCollab?: true; canEdit?: boolean;
}
export interface MotivatorSession {
id: string; title: string; participant: string;
resolvedParticipant: ResolvedCollaborator; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { cards: number };
workshopType: 'motivators'; isTeamCollab?: true; canEdit?: boolean;
}
export interface YearReviewSession {
id: string; title: string; participant: string;
resolvedParticipant: ResolvedCollaborator; year: number; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { items: number };
workshopType: 'year-review'; isTeamCollab?: true; canEdit?: boolean;
}
export interface WeeklyCheckInSession {
id: string; title: string; participant: string;
resolvedParticipant: ResolvedCollaborator; date: Date; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { items: number };
workshopType: 'weekly-checkin'; isTeamCollab?: true; canEdit?: boolean;
}
export interface WeatherSession {
id: string; title: string; date: Date; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { entries: number };
workshopType: 'weather'; isTeamCollab?: true; canEdit?: boolean;
}
export interface GifMoodSession {
id: string; title: string; date: Date; updatedAt: Date;
isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[]; _count: { items: number };
workshopType: 'gif-mood'; isTeamCollab?: true; canEdit?: boolean;
}
export type AnySession =
| SwotSession | MotivatorSession | YearReviewSession
| WeeklyCheckInSession | WeatherSession | GifMoodSession;
export interface WorkshopSessionTotals {
swot: number;
motivators: number;
'year-review': number;
'weekly-checkin': number;
weather: number;
'gif-mood': number;
}
export interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[];
gifMoodSessions: GifMoodSession[];
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
totals?: WorkshopSessionTotals;
}

View File

@@ -62,15 +62,17 @@ export default function EditOKRPage() {
order?: number; order?: number;
}; };
const handleSubmit = async (data: CreateOKRInput & { const handleSubmit = async (
startDate: Date | string; data: CreateOKRInput & {
endDate: Date | string; startDate: Date | string;
keyResultsUpdates?: { endDate: Date | string;
create?: CreateKeyResultInput[]; keyResultsUpdates?: {
update?: KeyResultUpdate[]; create?: CreateKeyResultInput[];
delete?: string[] update?: KeyResultUpdate[];
} delete?: string[];
}) => { };
}
) => {
// Convert to UpdateOKRInput format // Convert to UpdateOKRInput format
const updateData = { const updateData = {
objective: data.objective, objective: data.objective,
@@ -119,7 +121,7 @@ export default function EditOKRPage() {
if (loading) { if (loading) {
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4">
<div className="text-center">Chargement...</div> <div className="text-center">Chargement...</div>
</main> </main>
); );
@@ -127,7 +129,7 @@ export default function EditOKRPage() {
if (!okr) { if (!okr) {
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4">
<div className="text-center">OKR non trouvé</div> <div className="text-center">OKR non trouvé</div>
</main> </main>
); );
@@ -145,7 +147,7 @@ export default function EditOKRPage() {
}; };
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4">
<div className="mb-6"> <div className="mb-6">
<Link href={`/teams/${teamId}/okrs/${okrId}`} className="text-muted hover:text-foreground"> <Link href={`/teams/${teamId}/okrs/${okrId}`} className="text-muted hover:text-foreground">
Retour à l&apos;OKR Retour à l&apos;OKR
@@ -164,4 +166,3 @@ export default function EditOKRPage() {
</main> </main>
); );
} }

View File

@@ -124,7 +124,7 @@ export default function OKRDetailPage() {
if (loading) { if (loading) {
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4">
<div className="text-center">Chargement...</div> <div className="text-center">Chargement...</div>
</main> </main>
); );
@@ -132,7 +132,7 @@ export default function OKRDetailPage() {
if (!okr) { if (!okr) {
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4">
<div className="text-center">OKR non trouvé</div> <div className="text-center">OKR non trouvé</div>
</main> </main>
); );
@@ -145,7 +145,7 @@ export default function OKRDetailPage() {
const canDelete = okr.permissions?.canDelete ?? false; const canDelete = okr.permissions?.canDelete ?? false;
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4">
<div className="mb-6"> <div className="mb-6">
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground"> <Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
Retour à l&apos;équipe Retour à l&apos;équipe
@@ -186,9 +186,7 @@ export default function OKRDetailPage() {
> >
{okr.period} {okr.period}
</Badge> </Badge>
<Badge style={getOKRStatusColor(okr.status)}> <Badge style={getOKRStatusColor(okr.status)}>{OKR_STATUS_LABELS[okr.status]}</Badge>
{OKR_STATUS_LABELS[okr.status]}
</Badge>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -269,13 +267,10 @@ export default function OKRDetailPage() {
/> />
)) ))
) : ( ) : (
<Card className="p-8 text-center text-muted"> <Card className="p-8 text-center text-muted">Aucun Key Result défini</Card>
Aucun Key Result défini
</Card>
)} )}
</div> </div>
</div> </div>
</main> </main>
); );
} }

View File

@@ -54,14 +54,14 @@ export default function NewOKRPage() {
if (loading) { if (loading) {
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4">
<div className="text-center">Chargement...</div> <div className="text-center">Chargement...</div>
</main> </main>
); );
} }
return ( return (
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4">
<div className="mb-6"> <div className="mb-6">
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground"> <Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
Retour à l&apos;équipe Retour à l&apos;équipe
@@ -79,4 +79,3 @@ export default function NewOKRPage() {
</main> </main>
); );
} }

View File

@@ -6,8 +6,7 @@ import { getTeamOKRs } from '@/services/okrs';
import { TeamDetailClient } from '@/components/teams/TeamDetailClient'; import { TeamDetailClient } from '@/components/teams/TeamDetailClient';
import { DeleteTeamButton } from '@/components/teams/DeleteTeamButton'; import { DeleteTeamButton } from '@/components/teams/DeleteTeamButton';
import { OKRsList } from '@/components/okrs'; import { OKRsList } from '@/components/okrs';
import { Button } from '@/components/ui'; import { Button, Card, PageHeader } from '@/components/ui';
import { Card } from '@/components/ui';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { TeamMember } from '@/lib/types'; import type { TeamMember } from '@/lib/types';
@@ -39,41 +38,36 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
const okrsData = await getTeamOKRs(id); const okrsData = await getTeamOKRs(id);
return ( return (
<main className="mx-auto max-w-7xl px-4 py-8"> <main className="mx-auto max-w-7xl px-4">
{/* Header */} <div className="mb-2">
<div className="mb-8"> <Link href="/teams" className="text-sm text-muted hover:text-foreground">
<div className="mb-4 flex items-center gap-2"> Retour aux équipes
<Link href="/teams" className="text-muted hover:text-foreground"> </Link>
Retour aux équipes </div>
</Link> <PageHeader
</div> emoji="👥"
<div className="flex items-start justify-between"> title={team.name}
<div> subtitle={team.description ?? undefined}
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2"> actions={
<span className="text-3xl">👥</span> isAdmin ? (
{team.name}
</h1>
{team.description && <p className="mt-2 text-muted">{team.description}</p>}
</div>
{isAdmin && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href={`/teams/${id}/okrs/new`}> <Link href={`/teams/${id}/okrs/new`}>
<Button className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"> <Button variant="brand" size="sm">
Définir un OKR Définir un OKR
</Button> </Button>
</Link> </Link>
<DeleteTeamButton teamId={id} teamName={team.name} /> <DeleteTeamButton teamId={id} teamName={team.name} />
</div> </div>
)} ) : undefined
</div> }
</div> />
{/* Members Section */} {/* Members Section */}
<Card className="mb-8 p-6"> <Card className="mb-8 p-6">
<TeamDetailClient <TeamDetailClient
members={team.members as unknown as TeamMember[]} members={team.members as unknown as TeamMember[]}
teamId={id} teamId={id}
isAdmin={isAdmin} isAdmin={isAdmin}
/> />
</Card> </Card>
@@ -82,4 +76,3 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
</main> </main>
); );
} }

Some files were not shown because too many files have changed in this diff Show More