Compare commits
17 Commits
2e00522bfc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc1cc47f18 | |||
| 3d4803f975 | |||
| 5b45f18ad9 | |||
| f9ed732f1c | |||
| a8c05aa841 | |||
| 2d266f89f9 | |||
| 6baa9bfada | |||
| f2c1b195b3 | |||
| 367eea6ee8 | |||
| dcc769a930 | |||
| 313ad53e2e | |||
| 8bff21bede | |||
| 4aea17124e | |||
| db7a0cef96 | |||
| 9a43980412 | |||
| 09a849279b | |||
| b1ba43fd30 |
152
.claude/commands/opsx/apply.md
Normal file
152
.claude/commands/opsx/apply.md
Normal 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
|
||||
157
.claude/commands/opsx/archive.md
Normal file
157
.claude/commands/opsx/archive.md
Normal 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
|
||||
173
.claude/commands/opsx/explore.md
Normal file
173
.claude/commands/opsx/explore.md
Normal 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
|
||||
106
.claude/commands/opsx/propose.md
Normal file
106
.claude/commands/opsx/propose.md
Normal 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
|
||||
156
.claude/skills/openspec-apply-change/SKILL.md
Normal file
156
.claude/skills/openspec-apply-change/SKILL.md
Normal 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
|
||||
114
.claude/skills/openspec-archive-change/SKILL.md
Normal file
114
.claude/skills/openspec-archive-change/SKILL.md
Normal 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
|
||||
288
.claude/skills/openspec-explore/SKILL.md
Normal file
288
.claude/skills/openspec-explore/SKILL.md
Normal 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
|
||||
110
.claude/skills/openspec-propose/SKILL.md
Normal file
110
.claude/skills/openspec-propose/SKILL.md
Normal 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
|
||||
156
.codex/skills/openspec-apply-change/SKILL.md
Normal file
156
.codex/skills/openspec-apply-change/SKILL.md
Normal 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
|
||||
114
.codex/skills/openspec-archive-change/SKILL.md
Normal file
114
.codex/skills/openspec-archive-change/SKILL.md
Normal 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
|
||||
288
.codex/skills/openspec-explore/SKILL.md
Normal file
288
.codex/skills/openspec-explore/SKILL.md
Normal 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
|
||||
110
.codex/skills/openspec-propose/SKILL.md
Normal file
110
.codex/skills/openspec-propose/SKILL.md
Normal 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
|
||||
152
.cursor/commands/opsx-apply.md
Normal file
152
.cursor/commands/opsx-apply.md
Normal 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
|
||||
157
.cursor/commands/opsx-archive.md
Normal file
157
.cursor/commands/opsx-archive.md
Normal 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
|
||||
173
.cursor/commands/opsx-explore.md
Normal file
173
.cursor/commands/opsx-explore.md
Normal 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
|
||||
106
.cursor/commands/opsx-propose.md
Normal file
106
.cursor/commands/opsx-propose.md
Normal 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
|
||||
156
.cursor/skills/openspec-apply-change/SKILL.md
Normal file
156
.cursor/skills/openspec-apply-change/SKILL.md
Normal 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
|
||||
114
.cursor/skills/openspec-archive-change/SKILL.md
Normal file
114
.cursor/skills/openspec-archive-change/SKILL.md
Normal 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
|
||||
288
.cursor/skills/openspec-explore/SKILL.md
Normal file
288
.cursor/skills/openspec-explore/SKILL.md
Normal 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
|
||||
110
.cursor/skills/openspec-propose/SKILL.md
Normal file
110
.cursor/skills/openspec-propose/SKILL.md
Normal 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
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
AUTH_URL: ${{ vars.AUTH_URL }}
|
||||
DATA_VOLUME_PATH: ${{ vars.DATA_VOLUME_PATH }}
|
||||
run: |
|
||||
docker compose up -d --build
|
||||
BUILDKIT_PROGRESS=plain docker compose up -d --build
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-09
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
33
openspec/changes/archive/2026-03-09-perf-quick-wins/tasks.md
Normal file
33
openspec/changes/archive/2026-03-09-perf-quick-wins/tasks.md
Normal 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
|
||||
2
openspec/changes/perf-data-optimization/.openspec.yaml
Normal file
2
openspec/changes/perf-data-optimization/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-09
|
||||
59
openspec/changes/perf-data-optimization/design.md
Normal file
59
openspec/changes/perf-data-optimization/design.md
Normal 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)
|
||||
29
openspec/changes/perf-data-optimization/proposal.md
Normal file
29
openspec/changes/perf-data-optimization/proposal.md
Normal 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)
|
||||
@@ -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
|
||||
30
openspec/changes/perf-data-optimization/tasks.md
Normal file
30
openspec/changes/perf-data-optimization/tasks.md
Normal 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
|
||||
2
openspec/changes/perf-realtime-scale/.openspec.yaml
Normal file
2
openspec/changes/perf-realtime-scale/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-09
|
||||
65
openspec/changes/perf-realtime-scale/design.md
Normal file
65
openspec/changes/perf-realtime-scale/design.md
Normal 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.
|
||||
29
openspec/changes/perf-realtime-scale/proposal.md
Normal file
29
openspec/changes/perf-realtime-scale/proposal.md
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
36
openspec/changes/perf-realtime-scale/tasks.md
Normal file
36
openspec/changes/perf-realtime-scale/tasks.md
Normal 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
20
openspec/config.yaml
Normal 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
|
||||
18
openspec/specs/perf-loading-states/spec.md
Normal file
18
openspec/specs/perf-loading-states/spec.md
Normal 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
|
||||
@@ -13,6 +13,9 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint",
|
||||
"prettier": "prettier --write ."
|
||||
},
|
||||
@@ -37,12 +40,15 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.7.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
4447
pnpm-lock.yaml
generated
4447
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_name_idx" ON "User"("name");
|
||||
@@ -45,6 +45,8 @@ model User {
|
||||
teamMembers TeamMember[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model Session {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
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';
|
||||
|
||||
@@ -20,6 +21,7 @@ export async function createGifMoodSession(data: { title: string; date?: Date })
|
||||
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);
|
||||
@@ -62,6 +64,7 @@ export async function updateGifMoodSession(
|
||||
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);
|
||||
@@ -79,6 +82,7 @@ export async function deleteGifMoodSession(sessionId: string) {
|
||||
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);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import * as motivatorsService from '@/services/moving-motivators';
|
||||
import { sessionsListTag } from '@/lib/cache-tags';
|
||||
import { broadcastToMotivatorSession } from '@/app/api/motivators/[id]/subscribe/route';
|
||||
|
||||
// ============================================
|
||||
// Session Actions
|
||||
@@ -54,9 +56,11 @@ export async function updateMotivatorSession(
|
||||
data
|
||||
);
|
||||
|
||||
broadcastToMotivatorSession(sessionId, { type: 'SESSION_UPDATED' });
|
||||
revalidatePath(`/motivators/${sessionId}`);
|
||||
revalidatePath('/motivators');
|
||||
revalidatePath('/sessions'); // Also revalidate unified workshops page
|
||||
revalidateTag(sessionsListTag(authSession.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (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);
|
||||
revalidatePath('/motivators');
|
||||
revalidatePath('/sessions'); // Also revalidate unified workshops page
|
||||
revalidateTag(sessionsListTag(authSession.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting motivator session:', error);
|
||||
@@ -121,6 +126,7 @@ export async function updateMotivatorCard(
|
||||
);
|
||||
}
|
||||
|
||||
broadcastToMotivatorSession(sessionId, { type: 'CARD_UPDATED' });
|
||||
revalidatePath(`/motivators/${sessionId}`);
|
||||
return { success: true, data: card };
|
||||
} catch (error) {
|
||||
@@ -152,6 +158,7 @@ export async function reorderMotivatorCards(sessionId: string, cardIds: string[]
|
||||
{ cardIds }
|
||||
);
|
||||
|
||||
broadcastToMotivatorSession(sessionId, { type: 'CARDS_REORDERED' });
|
||||
revalidatePath(`/motivators/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
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) {
|
||||
const session = await auth();
|
||||
@@ -28,8 +30,10 @@ export async function updateSessionTitle(sessionId: string, title: string) {
|
||||
title: title.trim(),
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'SESSION_UPDATED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(session.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating session title:', error);
|
||||
@@ -61,8 +65,10 @@ export async function updateSessionCollaborator(sessionId: string, collaborator:
|
||||
collaborator: collaborator.trim(),
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'SESSION_UPDATED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(session.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating session collaborator:', error);
|
||||
@@ -106,8 +112,10 @@ export async function updateSwotSession(
|
||||
updateData
|
||||
);
|
||||
|
||||
broadcastToSession(sessionId, { type: 'SESSION_UPDATED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(session.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating session:', error);
|
||||
@@ -129,6 +137,7 @@ export async function deleteSwotSession(sessionId: string) {
|
||||
}
|
||||
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(session.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
|
||||
49
src/actions/sessions-pagination.ts
Normal file
49
src/actions/sessions-pagination.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import * as sessionsService from '@/services/sessions';
|
||||
import { broadcastToSession } from '@/app/api/sessions/[id]/subscribe/route';
|
||||
import type { SwotCategory } from '@prisma/client';
|
||||
|
||||
// ============================================
|
||||
@@ -31,6 +32,7 @@ export async function createSwotItem(
|
||||
category: item.category,
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'ITEM_CREATED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -61,6 +63,7 @@ export async function updateSwotItem(
|
||||
...data,
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'ITEM_UPDATED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -86,6 +89,7 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
|
||||
itemId,
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'ITEM_DELETED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -114,6 +118,7 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
|
||||
duplicatedFrom: itemId,
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'ITEM_CREATED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -146,6 +151,7 @@ export async function moveSwotItem(
|
||||
newOrder,
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'ITEM_MOVED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -185,6 +191,7 @@ export async function createAction(
|
||||
linkedItemIds: data.linkedItemIds,
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'ACTION_CREATED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: action };
|
||||
} catch (error) {
|
||||
@@ -221,6 +228,7 @@ export async function updateAction(
|
||||
...data,
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'ACTION_UPDATED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: action };
|
||||
} catch (error) {
|
||||
@@ -246,6 +254,7 @@ export async function deleteAction(actionId: string, sessionId: string) {
|
||||
actionId,
|
||||
});
|
||||
|
||||
broadcastToSession(sessionId, { type: 'ACTION_DELETED' });
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import * as weatherService from '@/services/weather';
|
||||
import { sessionsListTag } from '@/lib/cache-tags';
|
||||
import { getUserById } from '@/services/auth';
|
||||
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);
|
||||
revalidatePath('/weather');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(session.user.id), 'default');
|
||||
return { success: true, data: weatherSession };
|
||||
} catch (error) {
|
||||
console.error('Error creating weather session:', error);
|
||||
@@ -65,6 +67,7 @@ export async function updateWeatherSession(
|
||||
revalidatePath(`/weather/${sessionId}`);
|
||||
revalidatePath('/weather');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(authSession.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (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);
|
||||
revalidatePath('/weather');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(authSession.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting weather session:', error);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
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';
|
||||
|
||||
// ============================================
|
||||
@@ -36,6 +38,7 @@ export async function createWeeklyCheckInSession(data: {
|
||||
}
|
||||
revalidatePath('/weekly-checkin');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(session.user.id), 'default');
|
||||
return { success: true, data: weeklyCheckInSession };
|
||||
} catch (error) {
|
||||
console.error('Error creating weekly check-in session:', error);
|
||||
@@ -63,9 +66,11 @@ export async function updateWeeklyCheckInSession(
|
||||
data
|
||||
);
|
||||
|
||||
broadcastToWeeklyCheckInSession(sessionId, { type: 'SESSION_UPDATED' });
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
revalidatePath('/weekly-checkin');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(authSession.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (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);
|
||||
revalidatePath('/weekly-checkin');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(authSession.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (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}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -169,6 +176,7 @@ export async function updateWeeklyCheckInItem(
|
||||
}
|
||||
);
|
||||
|
||||
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_UPDATED' });
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -203,6 +211,7 @@ export async function deleteWeeklyCheckInItem(itemId: string, sessionId: string)
|
||||
{ itemId }
|
||||
);
|
||||
|
||||
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_DELETED' });
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -246,6 +255,7 @@ export async function moveWeeklyCheckInItem(
|
||||
}
|
||||
);
|
||||
|
||||
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_MOVED' });
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -284,6 +294,7 @@ export async function reorderWeeklyCheckInItems(
|
||||
{ category, itemIds }
|
||||
);
|
||||
|
||||
broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEMS_REORDERED' });
|
||||
revalidatePath(`/weekly-checkin/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
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';
|
||||
|
||||
// ============================================
|
||||
@@ -36,6 +38,7 @@ export async function createYearReviewSession(data: {
|
||||
}
|
||||
revalidatePath('/year-review');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(session.user.id), 'default');
|
||||
return { success: true, data: yearReviewSession };
|
||||
} catch (error) {
|
||||
console.error('Error creating year review session:', error);
|
||||
@@ -63,9 +66,11 @@ export async function updateYearReviewSession(
|
||||
data
|
||||
);
|
||||
|
||||
broadcastToYearReviewSession(sessionId, { type: 'SESSION_UPDATED' });
|
||||
revalidatePath(`/year-review/${sessionId}`);
|
||||
revalidatePath('/year-review');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(authSession.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (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);
|
||||
revalidatePath('/year-review');
|
||||
revalidatePath('/sessions');
|
||||
revalidateTag(sessionsListTag(authSession.user.id), 'default');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting year review session:', error);
|
||||
@@ -124,6 +130,7 @@ export async function createYearReviewItem(
|
||||
}
|
||||
);
|
||||
|
||||
broadcastToYearReviewSession(sessionId, { type: 'ITEM_CREATED' });
|
||||
revalidatePath(`/year-review/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -162,6 +169,7 @@ export async function updateYearReviewItem(
|
||||
}
|
||||
);
|
||||
|
||||
broadcastToYearReviewSession(sessionId, { type: 'ITEM_UPDATED' });
|
||||
revalidatePath(`/year-review/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -193,6 +201,7 @@ export async function deleteYearReviewItem(itemId: string, sessionId: string) {
|
||||
{ itemId }
|
||||
);
|
||||
|
||||
broadcastToYearReviewSession(sessionId, { type: 'ITEM_DELETED' });
|
||||
revalidatePath(`/year-review/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -233,6 +242,7 @@ export async function moveYearReviewItem(
|
||||
}
|
||||
);
|
||||
|
||||
broadcastToYearReviewSession(sessionId, { type: 'ITEM_MOVED' });
|
||||
revalidatePath(`/year-review/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -268,6 +278,7 @@ export async function reorderYearReviewItems(
|
||||
{ category, itemIds }
|
||||
);
|
||||
|
||||
broadcastToYearReviewSession(sessionId, { type: 'ITEMS_REORDERED' });
|
||||
revalidatePath(`/year-review/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { RocketIcon } from '@/components/ui';
|
||||
import { Button, Input, RocketIcon } from '@/components/ui';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -62,42 +62,32 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
label="Mot de passe"
|
||||
required
|
||||
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="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button type="submit" disabled={loading} loading={loading} className="w-full">
|
||||
{loading ? 'Connexion...' : 'Se connecter'}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted">
|
||||
Pas encore de compte ?{' '}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { RocketIcon } from '@/components/ui';
|
||||
import { Button, Input, RocketIcon } from '@/components/ui';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -91,74 +91,55 @@ export default function RegisterPage() {
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
label="Nom"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
label="Mot de passe"
|
||||
required
|
||||
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="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="mb-2 block text-sm font-medium text-foreground"
|
||||
>
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
label="Confirmer le mot de passe"
|
||||
required
|
||||
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="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button type="submit" disabled={loading} loading={loading} className="w-full">
|
||||
{loading ? 'Création...' : 'Créer mon compte'}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted">
|
||||
Déjà un compte ?{' '}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { canAccessGifMoodSession, getGifMoodSessionEvents } from '@/services/gif-mood';
|
||||
import { createBroadcaster } from '@/lib/broadcast';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Store active connections per session
|
||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||
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;
|
||||
@@ -20,60 +28,31 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
let lastEventTime = new Date();
|
||||
let unsubscribe: () => void = () => {};
|
||||
let controller: ReadableStreamDefaultController;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(ctrl) {
|
||||
controller = ctrl;
|
||||
|
||||
if (!connections.has(sessionId)) {
|
||||
connections.set(sessionId, new Set());
|
||||
}
|
||||
connections.get(sessionId)!.add(controller);
|
||||
|
||||
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() {
|
||||
connections.get(sessionId)?.delete(controller);
|
||||
if (connections.get(sessionId)?.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const events = await getGifMoodSessionEvents(sessionId, lastEventTime);
|
||||
if (events.length > 0) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const event of events) {
|
||||
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 {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(pollInterval);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
@@ -84,29 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function broadcastToGifMoodSession(sessionId: string, event: object) {
|
||||
try {
|
||||
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 {
|
||||
sessionConnections.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionConnections.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SSE Broadcast] Error broadcasting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { canAccessMotivatorSession, getMotivatorSessionEvents } from '@/services/moving-motivators';
|
||||
import { createBroadcaster } from '@/lib/broadcast';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Store active connections per session
|
||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||
const { subscribe, broadcast } = createBroadcaster(getMotivatorSessionEvents, (event) => ({
|
||||
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 }> }) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
const hasAccess = await canAccessMotivatorSession(sessionId, session.user.id);
|
||||
if (!hasAccess) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
let lastEventTime = new Date();
|
||||
let unsubscribe: () => void = () => {};
|
||||
let controller: ReadableStreamDefaultController;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(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();
|
||||
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() {
|
||||
// Remove connection on close
|
||||
connections.get(sessionId)?.delete(controller);
|
||||
if (connections.get(sessionId)?.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
clearInterval(pollInterval);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { canAccessSession, getSessionEvents } from '@/services/sessions';
|
||||
import { createBroadcaster } from '@/lib/broadcast';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Store active connections per session
|
||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||
const { subscribe, broadcast } = createBroadcaster(getSessionEvents, (event) => ({
|
||||
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 }> }) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
const hasAccess = await canAccessSession(sessionId, session.user.id);
|
||||
if (!hasAccess) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
let lastEventTime = new Date();
|
||||
let unsubscribe: () => void = () => {};
|
||||
let controller: ReadableStreamDefaultController;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(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();
|
||||
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() {
|
||||
// Remove connection on close
|
||||
connections.get(sessionId)?.delete(controller);
|
||||
if (connections.get(sessionId)?.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
clearInterval(pollInterval);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/services/database';
|
||||
import { shareSession } from '@/services/sessions';
|
||||
import { sessionsListTag } from '@/lib/cache-tags';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -63,6 +65,7 @@ export async function POST(request: Request) {
|
||||
console.error('Auto-share failed:', shareError);
|
||||
}
|
||||
|
||||
revalidateTag(sessionsListTag(session.user.id), 'default');
|
||||
return NextResponse.json(newSession, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { canAccessWeatherSession, getWeatherSessionEvents } from '@/services/weather';
|
||||
import { createBroadcaster } from '@/lib/broadcast';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Store active connections per session
|
||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||
const { subscribe, broadcast } = createBroadcaster(getWeatherSessionEvents, (event) => ({
|
||||
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 }> }) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
const hasAccess = await canAccessWeatherSession(sessionId, session.user.id);
|
||||
if (!hasAccess) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
let lastEventTime = new Date();
|
||||
let unsubscribe: () => void = () => {};
|
||||
let controller: ReadableStreamDefaultController;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(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();
|
||||
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() {
|
||||
// Remove connection on close
|
||||
connections.get(sessionId)?.delete(controller);
|
||||
if (connections.get(sessionId)?.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
clearInterval(pollInterval);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
@@ -92,45 +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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,19 @@ import {
|
||||
canAccessWeeklyCheckInSession,
|
||||
getWeeklyCheckInSessionEvents,
|
||||
} from '@/services/weekly-checkin';
|
||||
import { createBroadcaster } from '@/lib/broadcast';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Store active connections per session
|
||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||
const { subscribe, broadcast } = createBroadcaster(getWeeklyCheckInSessionEvents, (event) => ({
|
||||
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 }> }) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
const hasAccess = await canAccessWeeklyCheckInSession(sessionId, session.user.id);
|
||||
if (!hasAccess) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
let lastEventTime = new Date();
|
||||
let unsubscribe: () => void = () => {};
|
||||
let controller: ReadableStreamDefaultController;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(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();
|
||||
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() {
|
||||
// Remove connection on close
|
||||
connections.get(sessionId)?.delete(controller);
|
||||
if (connections.get(sessionId)?.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
clearInterval(pollInterval);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { canAccessYearReviewSession, getYearReviewSessionEvents } from '@/services/year-review';
|
||||
import { createBroadcaster } from '@/lib/broadcast';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Store active connections per session
|
||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||
const { subscribe, broadcast } = createBroadcaster(getYearReviewSessionEvents, (event) => ({
|
||||
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 }> }) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
const hasAccess = await canAccessYearReviewSession(sessionId, session.user.id);
|
||||
if (!hasAccess) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
let lastEventTime = new Date();
|
||||
let unsubscribe: () => void = () => {};
|
||||
let controller: ReadableStreamDefaultController;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(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();
|
||||
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() {
|
||||
// Remove connection on close
|
||||
connections.get(sessionId)?.delete(controller);
|
||||
if (connections.get(sessionId)?.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
clearInterval(pollInterval);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
@@ -92,28 +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);
|
||||
}
|
||||
}
|
||||
|
||||
525
src/app/design-system/page.tsx
Normal file
525
src/app/design-system/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||
import { getGifMoodSessionById } from '@/services/gif-mood';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import { GifMoodBoard, GifMoodLiveWrapper } from '@/components/gif-mood';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { EditableGifMoodTitle } from '@/components/ui/EditableGifMoodTitle';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface GifMoodSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -30,41 +27,16 @@ export default async function GifMoodSessionPage({ params }: GifMoodSessionPageP
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||
<Link href={getSessionsTabUrl('gif-mood')} className="hover:text-foreground">
|
||||
{getWorkshop('gif-mood').labelShort}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<EditableGifMoodTitle
|
||||
<SessionPageHeader
|
||||
workshopType="gif-mood"
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
badges={<Badge variant="primary">{session.items.length} GIFs</Badge>}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">{session.items.length} GIFs</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 */}
|
||||
<GifMoodLiveWrapper
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
DateInput,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { createGifMoodSession } from '@/actions/gif-mood';
|
||||
@@ -78,20 +79,14 @@ export default function NewGifMoodPage() {
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
<DateInput
|
||||
id="date"
|
||||
name="date"
|
||||
type="date"
|
||||
label="Date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card-hover p-4">
|
||||
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||
import { getMotivatorSessionById } from '@/services/moving-motivators';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import type { ResolvedCollaborator } from '@/services/auth';
|
||||
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
|
||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||
import { EditableMotivatorTitle } from '@/components/ui';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface MotivatorSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -32,50 +29,21 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||
<Link href={getSessionsTabUrl('motivators')} className="hover:text-foreground">
|
||||
{getWorkshop('motivators').label}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<EditableMotivatorTitle
|
||||
<SessionPageHeader
|
||||
workshopType="motivators"
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||
size="lg"
|
||||
showEmail
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
badges={
|
||||
<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 */}
|
||||
<MotivatorLiveWrapper
|
||||
|
||||
@@ -2,7 +2,7 @@ import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getUserOKRs } from '@/services/okrs';
|
||||
import { Card, PageHeader } from '@/components/ui';
|
||||
import { Card, PageHeader, getButtonClassName } from '@/components/ui';
|
||||
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
|
||||
import { comparePeriods } from '@/lib/okr-utils';
|
||||
|
||||
@@ -46,10 +46,11 @@ export default async function ObjectivesPage() {
|
||||
Vous n'avez pas encore d'OKR défini. Contactez un administrateur d'équipe
|
||||
pour en créer.
|
||||
</p>
|
||||
<Link href="/teams">
|
||||
<span className="inline-block rounded-lg bg-[var(--purple)] px-4 py-2 text-white hover:opacity-90">
|
||||
<Link
|
||||
href="/teams"
|
||||
className={getButtonClassName({ variant: 'brand' })}
|
||||
>
|
||||
Voir mes équipes
|
||||
</span>
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { getButtonClassName } from '@/components/ui';
|
||||
import { WORKSHOPS, getSessionsTabUrl } from '@/lib/workshops';
|
||||
|
||||
export default function Home() {
|
||||
@@ -888,14 +889,16 @@ function WorkshopCard({
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
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 }}
|
||||
>
|
||||
Démarrer
|
||||
</Link>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
|
||||
@@ -39,29 +39,20 @@ export function PasswordForm() {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="currentPassword"
|
||||
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||
>
|
||||
Mot de passe actuel
|
||||
</label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
label="Mot de passe actuel"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Nouveau mot de passe
|
||||
</label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
label="Nouveau mot de passe"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
@@ -71,15 +62,10 @@ export function PasswordForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||
>
|
||||
Confirmer le nouveau mot de passe
|
||||
</label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
label="Confirmer le nouveau mot de passe"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
|
||||
@@ -39,31 +39,23 @@ export function ProfileForm({ initialData }: ProfileFormProps) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Nom
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
label="Nom"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Votre nom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Button, DropdownMenu } from '@/components/ui';
|
||||
import { WORKSHOPS } from '@/lib/workshops';
|
||||
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||
|
||||
export function NewWorkshopDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(containerRef, () => setOpen(false), open);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<DropdownMenu
|
||||
panelClassName="absolute right-0 z-20 mt-2 w-60 rounded-xl border border-border bg-card py-1.5 shadow-lg"
|
||||
trigger={({ open, toggle }) => (
|
||||
<Button type="button" variant="primary" size="sm" onClick={toggle} className="gap-1.5">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
@@ -33,14 +23,16 @@ export function NewWorkshopDropdown() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-60 rounded-xl border border-border bg-card py-1.5 shadow-lg">
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{WORKSHOPS.map((w) => (
|
||||
<Link
|
||||
key={w.id}
|
||||
href={w.newPath}
|
||||
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="flex h-8 w-8 items-center justify-center rounded-lg text-base flex-shrink-0"
|
||||
@@ -54,8 +46,8 @@ export function NewWorkshopDropdown() {
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button, Modal, ModalFooter, Input, CollaboratorDisplay } from '@/components/ui';
|
||||
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';
|
||||
@@ -211,25 +220,21 @@ export function SessionCard({
|
||||
<>
|
||||
{(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'}`}>
|
||||
<button
|
||||
<IconButton
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); openEditModal(); }}
|
||||
className="p-1.5 rounded-lg bg-card border border-border text-muted hover:text-primary hover:bg-primary/5 shadow-sm"
|
||||
title="Modifier"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
label="Modifier"
|
||||
icon={<IconEdit />}
|
||||
variant="primary"
|
||||
className="bg-card shadow-sm"
|
||||
/>
|
||||
{(session.isOwner || session.isTeamCollab) && (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setShowDeleteModal(true); }}
|
||||
className="p-1.5 rounded-lg bg-card border border-border text-muted hover:text-destructive hover:bg-destructive/5 shadow-sm"
|
||||
title="Supprimer"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
label="Supprimer"
|
||||
icon={<IconTrash />}
|
||||
variant="destructive"
|
||||
className="bg-card shadow-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -247,19 +252,15 @@ export function SessionCard({
|
||||
|
||||
<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>
|
||||
<label htmlFor="edit-title" className="block text-sm font-medium text-foreground mb-1">Titre</label>
|
||||
<Input id="edit-title" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} placeholder="Titre de l'atelier" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-participant" className="block text-sm font-medium text-foreground mb-1">{workshop.participantLabel}</label>
|
||||
{!isWeather && !isGifMood && (
|
||||
<Input id="edit-participant" value={editParticipant} onChange={(e) => setEditParticipant(e.target.value)}
|
||||
<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="ghost" onClick={() => setShowEditModal(false)} disabled={isPending}>Annuler</Button>
|
||||
<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>
|
||||
@@ -272,7 +273,7 @@ export function SessionCard({
|
||||
<p className="text-muted">Êtes-vous sûr de vouloir supprimer <strong className="text-foreground">"{session.title}"</strong> ?</p>
|
||||
<p className="text-sm text-destructive">Cette action est irréversible. Toutes les données seront perdues.</p>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setShowDeleteModal(false)} disabled={isPending}>Annuler</Button>
|
||||
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={isPending}>Annuler</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>{isPending ? 'Suppression...' : 'Supprimer'}</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useEffect, useRef, useState, useTransition } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { CollaboratorDisplay } from '@/components/ui';
|
||||
import { type WorkshopTabType, WORKSHOPS, VALID_TAB_PARAMS } from '@/lib/workshops';
|
||||
import { type WorkshopTabType, VALID_TAB_PARAMS, type WorkshopTypeId } from '@/lib/workshops';
|
||||
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||
import { loadMoreSessions } from '@/actions/sessions-pagination';
|
||||
import {
|
||||
type CardView, type SortCol, type WorkshopTabsProps, type AnySession,
|
||||
TABLE_COLS, SORT_COLUMNS, TYPE_TABS,
|
||||
type CardView,
|
||||
type SortCol,
|
||||
type WorkshopTabsProps,
|
||||
type AnySession,
|
||||
TABLE_COLS,
|
||||
SORT_COLUMNS,
|
||||
TYPE_TABS,
|
||||
} from './workshop-session-types';
|
||||
import {
|
||||
getResolvedCollaborator, groupByPerson, getMonthGroup, sortSessions,
|
||||
getResolvedCollaborator,
|
||||
groupByPerson,
|
||||
getMonthGroup,
|
||||
sortSessions,
|
||||
} from './workshop-session-helpers';
|
||||
import { SessionCard } from './SessionCard';
|
||||
|
||||
@@ -33,7 +42,13 @@ function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||
function SortIcon({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
|
||||
if (!active) {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="opacity-30 flex-shrink-0">
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
className="opacity-30 flex-shrink-0"
|
||||
>
|
||||
<path d="M5 1.5L8 5H2L5 1.5Z" />
|
||||
<path d="M5 8.5L2 5H8L5 8.5Z" />
|
||||
</svg>
|
||||
@@ -72,27 +87,61 @@ function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 p-0.5 bg-card border border-border rounded-lg ml-auto flex-shrink-0 shadow-sm">
|
||||
{btn('grid', 'Grille',
|
||||
{btn(
|
||||
'grid',
|
||||
'Grille',
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="4" height="4" rx="0.5" /><rect x="5" y="0" width="4" height="4" rx="0.5" /><rect x="10" y="0" width="4" height="4" rx="0.5" />
|
||||
<rect x="0" y="5" width="4" height="4" rx="0.5" /><rect x="5" y="5" width="4" height="4" rx="0.5" /><rect x="10" y="5" width="4" height="4" rx="0.5" />
|
||||
<rect x="0" y="10" width="4" height="4" rx="0.5" /><rect x="5" y="10" width="4" height="4" rx="0.5" /><rect x="10" y="10" width="4" height="4" rx="0.5" />
|
||||
<rect x="0" y="0" width="4" height="4" rx="0.5" />
|
||||
<rect x="5" y="0" width="4" height="4" rx="0.5" />
|
||||
<rect x="10" y="0" width="4" height="4" rx="0.5" />
|
||||
<rect x="0" y="5" width="4" height="4" rx="0.5" />
|
||||
<rect x="5" y="5" width="4" height="4" rx="0.5" />
|
||||
<rect x="10" y="5" width="4" height="4" rx="0.5" />
|
||||
<rect x="0" y="10" width="4" height="4" rx="0.5" />
|
||||
<rect x="5" y="10" width="4" height="4" rx="0.5" />
|
||||
<rect x="10" y="10" width="4" height="4" rx="0.5" />
|
||||
</svg>
|
||||
)}
|
||||
{btn('list', 'Liste',
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<line x1="0" y1="2.5" x2="14" y2="2.5" /><line x1="0" y1="7" x2="14" y2="7" /><line x1="0" y1="11.5" x2="14" y2="11.5" />
|
||||
{btn(
|
||||
'list',
|
||||
'Liste',
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="0" y1="2.5" x2="14" y2="2.5" />
|
||||
<line x1="0" y1="7" x2="14" y2="7" />
|
||||
<line x1="0" y1="11.5" x2="14" y2="11.5" />
|
||||
</svg>
|
||||
)}
|
||||
{btn('table', 'Tableau',
|
||||
{btn(
|
||||
'table',
|
||||
'Tableau',
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="14" height="3.5" rx="0.5" />
|
||||
<rect x="0" y="5" width="6" height="2.5" rx="0.5" /><rect x="8" y="5" width="6" height="2.5" rx="0.5" />
|
||||
<rect x="0" y="9.5" width="6" height="2.5" rx="0.5" /><rect x="8" y="9.5" width="6" height="2.5" rx="0.5" />
|
||||
<rect x="0" y="5" width="6" height="2.5" rx="0.5" />
|
||||
<rect x="8" y="5" width="6" height="2.5" rx="0.5" />
|
||||
<rect x="0" y="9.5" width="6" height="2.5" rx="0.5" />
|
||||
<rect x="8" y="9.5" width="6" height="2.5" rx="0.5" />
|
||||
</svg>
|
||||
)}
|
||||
{btn('timeline', 'Chronologique',
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
{btn(
|
||||
'timeline',
|
||||
'Chronologique',
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="3" y1="0" x2="3" y2="14" />
|
||||
<circle cx="3" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
<line x1="5" y1="2.5" x2="14" y2="2.5" />
|
||||
@@ -108,11 +157,23 @@ function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView)
|
||||
|
||||
// ─── TabButton ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TabButton({ active, onClick, icon, label, count }: {
|
||||
active: boolean; onClick: () => void; icon: string; label: string; count: number;
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium text-sm transition-all duration-150 shadow-sm ${
|
||||
active
|
||||
? 'bg-primary text-primary-foreground shadow-md'
|
||||
@@ -121,7 +182,9 @@ function TabButton({ active, onClick, icon, label, count }: {
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
|
||||
<span
|
||||
className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
@@ -131,10 +194,17 @@ function TabButton({ active, onClick, icon, label, count }: {
|
||||
// ─── TypeFilterDropdown ───────────────────────────────────────────────────────
|
||||
|
||||
function TypeFilterDropdown({
|
||||
activeTab, setActiveTab, open, onOpenChange, counts,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
open,
|
||||
onOpenChange,
|
||||
counts,
|
||||
}: {
|
||||
activeTab: WorkshopTabType; setActiveTab: (t: WorkshopTabType) => void;
|
||||
open: boolean; onOpenChange: (v: boolean) => void; counts: Record<string, number>;
|
||||
activeTab: WorkshopTabType;
|
||||
setActiveTab: (t: WorkshopTabType) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
counts: Record<string, number>;
|
||||
}) {
|
||||
const typeTabs = TYPE_TABS.filter((t) => t.value !== 'all' && t.value !== 'team');
|
||||
const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
|
||||
@@ -156,25 +226,55 @@ function TypeFilterDropdown({
|
||||
>
|
||||
<span>{isTypeSelected ? current.icon : '🔖'}</span>
|
||||
<span>{isTypeSelected ? current.label : 'Type'}</span>
|
||||
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${isTypeSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
|
||||
<span
|
||||
className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${isTypeSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}
|
||||
>
|
||||
{isTypeSelected ? (counts[activeTab] ?? 0) : totalCount}
|
||||
</span>
|
||||
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 z-20 mt-2 w-48 rounded-xl border border-border bg-card py-1.5 shadow-lg">
|
||||
<button type="button" onClick={() => { setActiveTab('all'); onOpenChange(false); }}
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover border-b border-border transition-colors">
|
||||
<span className="flex items-center gap-2"><span>📋</span><span>Tous les types</span></span>
|
||||
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{totalCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab('all');
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover border-b border-border transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>📋</span>
|
||||
<span>Tous les types</span>
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{totalCount}
|
||||
</span>
|
||||
</button>
|
||||
{typeTabs.map((t) => (
|
||||
<button key={t.value} type="button" onClick={() => { setActiveTab(t.value); onOpenChange(false); }}
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover transition-colors">
|
||||
<span className="flex items-center gap-2"><span>{t.icon}</span><span>{t.label}</span></span>
|
||||
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{counts[t.value] ?? 0}</span>
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab(t.value);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{t.icon}</span>
|
||||
<span>{t.label}</span>
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{counts[t.value] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -186,16 +286,25 @@ function TypeFilterDropdown({
|
||||
// ─── SessionsGrid ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SessionsGrid({
|
||||
sessions, view, isTeamCollab = false,
|
||||
sessions,
|
||||
view,
|
||||
isTeamCollab = false,
|
||||
}: {
|
||||
sessions: AnySession[]; view: CardView; isTeamCollab?: boolean;
|
||||
sessions: AnySession[];
|
||||
view: CardView;
|
||||
isTeamCollab?: boolean;
|
||||
}) {
|
||||
if (view === 'table') {
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
|
||||
<div className="grid text-[11px] font-semibold text-muted uppercase tracking-wider bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}>
|
||||
<div
|
||||
className="grid text-[11px] font-semibold text-muted uppercase tracking-wider bg-card-hover/60 border-b border-border"
|
||||
style={{ gridTemplateColumns: TABLE_COLS }}
|
||||
>
|
||||
{SORT_COLUMNS.map((col) => (
|
||||
<div key={col.key} className="px-4 py-2.5">{col.label}</div>
|
||||
<div key={col.key} className="px-4 py-2.5">
|
||||
{col.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
@@ -205,7 +314,11 @@ function SessionsGrid({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'}>
|
||||
<div
|
||||
className={
|
||||
view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'
|
||||
}
|
||||
>
|
||||
{sessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view={view} />
|
||||
))}
|
||||
@@ -216,7 +329,10 @@ function SessionsGrid({
|
||||
// ─── SortableTableView ────────────────────────────────────────────────────────
|
||||
|
||||
function SortableTableView({
|
||||
sessions, sortCol, sortDir, onSort,
|
||||
sessions,
|
||||
sortCol,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: {
|
||||
sessions: AnySession[];
|
||||
sortCol: SortCol;
|
||||
@@ -228,7 +344,10 @@ function SortableTableView({
|
||||
}
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
|
||||
<div className="grid bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}>
|
||||
<div
|
||||
className="grid bg-card-hover/60 border-b border-border"
|
||||
style={{ gridTemplateColumns: TABLE_COLS }}
|
||||
>
|
||||
{SORT_COLUMNS.map((col) => (
|
||||
<button
|
||||
key={col.key}
|
||||
@@ -258,20 +377,78 @@ function SortableTableView({
|
||||
// ─── WorkshopTabs ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function WorkshopTabs({
|
||||
swotSessions, motivatorSessions, yearReviewSessions,
|
||||
weeklyCheckInSessions, weatherSessions, gifMoodSessions,
|
||||
swotSessions: initialSwot,
|
||||
motivatorSessions: initialMotivators,
|
||||
yearReviewSessions: initialYearReview,
|
||||
weeklyCheckInSessions: initialWeeklyCheckIn,
|
||||
weatherSessions: initialWeather,
|
||||
gifMoodSessions: initialGifMood,
|
||||
teamCollabSessions = [],
|
||||
totals,
|
||||
}: WorkshopTabsProps) {
|
||||
const CARD_VIEW_STORAGE_KEY = 'sessions:cardView';
|
||||
const isCardView = (value: string): value is CardView =>
|
||||
value === 'grid' || value === 'list' || value === 'table' || value === 'timeline';
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
|
||||
const [cardView, setCardView] = useState<CardView>('grid');
|
||||
|
||||
// Per-type session lists (extended by load more)
|
||||
const [swotSessions, setSwotSessions] = useState(initialSwot);
|
||||
const [motivatorSessions, setMotivatorSessions] = useState(initialMotivators);
|
||||
const [yearReviewSessions, setYearReviewSessions] = useState(initialYearReview);
|
||||
const [weeklyCheckInSessions, setWeeklyCheckInSessions] = useState(initialWeeklyCheckIn);
|
||||
const [weatherSessions, setWeatherSessions] = useState(initialWeather);
|
||||
const [gifMoodSessions, setGifMoodSessions] = useState(initialGifMood);
|
||||
|
||||
const sessionsByType: Record<WorkshopTypeId, AnySession[]> = {
|
||||
swot: swotSessions,
|
||||
motivators: motivatorSessions,
|
||||
'year-review': yearReviewSessions,
|
||||
'weekly-checkin': weeklyCheckInSessions,
|
||||
weather: weatherSessions,
|
||||
'gif-mood': gifMoodSessions,
|
||||
};
|
||||
|
||||
const settersByType: Record<WorkshopTypeId, React.Dispatch<React.SetStateAction<AnySession[]>>> = {
|
||||
swot: setSwotSessions as React.Dispatch<React.SetStateAction<AnySession[]>>,
|
||||
motivators: setMotivatorSessions as React.Dispatch<React.SetStateAction<AnySession[]>>,
|
||||
'year-review': setYearReviewSessions as React.Dispatch<React.SetStateAction<AnySession[]>>,
|
||||
'weekly-checkin': setWeeklyCheckInSessions as React.Dispatch<React.SetStateAction<AnySession[]>>,
|
||||
weather: setWeatherSessions as React.Dispatch<React.SetStateAction<AnySession[]>>,
|
||||
'gif-mood': setGifMoodSessions as React.Dispatch<React.SetStateAction<AnySession[]>>,
|
||||
};
|
||||
|
||||
function handleLoadMore(type: WorkshopTypeId) {
|
||||
const current = sessionsByType[type];
|
||||
startTransition(async () => {
|
||||
const result = await loadMoreSessions(type, current.length);
|
||||
if (result) {
|
||||
settersByType[type]((prev) => [...prev, ...(result.items as AnySession[])]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const [cardView, setCardView] = useState<CardView>(() => {
|
||||
if (typeof window === 'undefined') return 'grid';
|
||||
const storedView = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
|
||||
return storedView && isCardView(storedView) ? storedView : 'grid';
|
||||
});
|
||||
const [sortCol, setSortCol] = useState<SortCol>('date');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardView);
|
||||
}, [cardView]);
|
||||
|
||||
const handleSort = (col: SortCol) => {
|
||||
if (sortCol === col) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else { setSortCol(col); setSortDir('asc'); }
|
||||
else {
|
||||
setSortCol(col);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const tabParam = searchParams.get('tab');
|
||||
@@ -288,18 +465,29 @@ export function WorkshopTabs({
|
||||
};
|
||||
|
||||
const allSessions: AnySession[] = [
|
||||
...swotSessions, ...motivatorSessions, ...yearReviewSessions,
|
||||
...weeklyCheckInSessions, ...weatherSessions, ...gifMoodSessions,
|
||||
...swotSessions,
|
||||
...motivatorSessions,
|
||||
...yearReviewSessions,
|
||||
...weeklyCheckInSessions,
|
||||
...weatherSessions,
|
||||
...gifMoodSessions,
|
||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
const filteredSessions: AnySession[] =
|
||||
activeTab === 'all' || activeTab === 'byPerson' ? allSessions
|
||||
: activeTab === 'team' ? teamCollabSessions
|
||||
: activeTab === 'swot' ? swotSessions
|
||||
: activeTab === 'motivators' ? motivatorSessions
|
||||
: activeTab === 'year-review' ? yearReviewSessions
|
||||
: activeTab === 'weekly-checkin' ? weeklyCheckInSessions
|
||||
: activeTab === 'gif-mood' ? gifMoodSessions
|
||||
activeTab === 'all' || activeTab === 'byPerson'
|
||||
? allSessions
|
||||
: activeTab === 'team'
|
||||
? teamCollabSessions
|
||||
: activeTab === 'swot'
|
||||
? swotSessions
|
||||
: activeTab === 'motivators'
|
||||
? motivatorSessions
|
||||
: activeTab === 'year-review'
|
||||
? yearReviewSessions
|
||||
: activeTab === 'weekly-checkin'
|
||||
? weeklyCheckInSessions
|
||||
: activeTab === 'gif-mood'
|
||||
? gifMoodSessions
|
||||
: weatherSessions;
|
||||
|
||||
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
||||
@@ -309,11 +497,14 @@ export function WorkshopTabs({
|
||||
const teamCollabFiltered = activeTab === 'all' ? teamCollabSessions : [];
|
||||
|
||||
const sessionsByPerson = groupByPerson(allSessions);
|
||||
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) => a[0].localeCompare(b[0], 'fr'));
|
||||
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) =>
|
||||
a[0].localeCompare(b[0], 'fr')
|
||||
);
|
||||
|
||||
// Timeline grouping
|
||||
const timelineSessions = [...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions)]
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
const timelineSessions = [
|
||||
...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions),
|
||||
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
const byMonth = new Map<string, AnySession[]>();
|
||||
timelineSessions.forEach((s) => {
|
||||
const key = getMonthGroup(s.updatedAt);
|
||||
@@ -326,7 +517,8 @@ export function WorkshopTabs({
|
||||
cardView === 'table' && activeTab !== 'byPerson'
|
||||
? sortSessions(
|
||||
activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions,
|
||||
sortCol, sortDir,
|
||||
sortCol,
|
||||
sortDir
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -334,19 +526,42 @@ export function WorkshopTabs({
|
||||
<div className="space-y-8">
|
||||
{/* Tabs + vue toggle */}
|
||||
<div className="flex gap-1.5 items-center flex-wrap">
|
||||
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')} icon="📋" label="Tous" count={allSessions.length} />
|
||||
<TabButton active={activeTab === 'byPerson'} onClick={() => setActiveTab('byPerson')} icon="👥" label="Par personne" count={sessionsByPerson.size} />
|
||||
<TabButton
|
||||
active={activeTab === 'all'}
|
||||
onClick={() => setActiveTab('all')}
|
||||
icon="📋"
|
||||
label="Tous"
|
||||
count={allSessions.length}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'byPerson'}
|
||||
onClick={() => setActiveTab('byPerson')}
|
||||
icon="👥"
|
||||
label="Par personne"
|
||||
count={sessionsByPerson.size}
|
||||
/>
|
||||
{teamCollabSessions.length > 0 && (
|
||||
<TabButton active={activeTab === 'team'} onClick={() => setActiveTab('team')} icon="🏢" label="Équipe" count={teamCollabSessions.length} />
|
||||
<TabButton
|
||||
active={activeTab === 'team'}
|
||||
onClick={() => setActiveTab('team')}
|
||||
icon="🏢"
|
||||
label="Équipe"
|
||||
count={teamCollabSessions.length}
|
||||
/>
|
||||
)}
|
||||
<div className="h-5 w-px bg-border mx-0.5 self-center" />
|
||||
<TypeFilterDropdown
|
||||
activeTab={activeTab} setActiveTab={setActiveTab}
|
||||
open={typeDropdownOpen} onOpenChange={setTypeDropdownOpen}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
open={typeDropdownOpen}
|
||||
onOpenChange={setTypeDropdownOpen}
|
||||
counts={{
|
||||
swot: swotSessions.length, motivators: motivatorSessions.length,
|
||||
'year-review': yearReviewSessions.length, 'weekly-checkin': weeklyCheckInSessions.length,
|
||||
weather: weatherSessions.length, 'gif-mood': gifMoodSessions.length,
|
||||
swot: totals?.swot ?? swotSessions.length,
|
||||
motivators: totals?.motivators ?? motivatorSessions.length,
|
||||
'year-review': totals?.['year-review'] ?? yearReviewSessions.length,
|
||||
'weekly-checkin': totals?.['weekly-checkin'] ?? weeklyCheckInSessions.length,
|
||||
weather: totals?.weather ?? weatherSessions.length,
|
||||
'gif-mood': totals?.['gif-mood'] ?? gifMoodSessions.length,
|
||||
team: teamCollabSessions.length,
|
||||
}}
|
||||
/>
|
||||
@@ -355,8 +570,12 @@ export function WorkshopTabs({
|
||||
|
||||
{/* ── Vue Tableau flat (colonnes triables) ──────────────────── */}
|
||||
{cardView === 'table' && activeTab !== 'byPerson' ? (
|
||||
<SortableTableView sessions={flatTableSessions} sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
|
||||
|
||||
<SortableTableView
|
||||
sessions={flatTableSessions}
|
||||
sortCol={sortCol}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
) : cardView === 'timeline' && activeTab !== 'byPerson' ? (
|
||||
/* ── Vue Timeline ────────────────────────────────────────── */
|
||||
byMonth.size === 0 ? (
|
||||
@@ -367,19 +586,25 @@ export function WorkshopTabs({
|
||||
<section key={period}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs font-semibold text-muted uppercase tracking-widest px-2 capitalize">{period}</span>
|
||||
<span className="text-xs font-semibold text-muted uppercase tracking-widest px-2 capitalize">
|
||||
{period}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab} view="list" />
|
||||
<SessionCard
|
||||
key={s.id}
|
||||
session={s}
|
||||
isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab}
|
||||
view="list"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
) : activeTab === 'byPerson' ? (
|
||||
/* ── Vue Par personne ───────────────────────────────────── */
|
||||
sortedPersons.length === 0 ? (
|
||||
@@ -396,21 +621,28 @@ export function WorkshopTabs({
|
||||
{sessions.length} atelier{sessions.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<SessionsGrid sessions={sessions} view={cardView === 'timeline' ? 'list' : cardView} />
|
||||
<SessionsGrid
|
||||
sessions={sessions}
|
||||
view={cardView === 'timeline' ? 'list' : cardView}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
) : activeTab === 'team' ? (
|
||||
/* ── Vue Équipe ─────────────────────────────────────────── */
|
||||
teamCollabSessions.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted">Aucun atelier de vos collaborateurs (non partagés)</div>
|
||||
<div className="text-center py-12 text-muted">
|
||||
Aucun atelier de vos collaborateurs (non partagés)
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<SectionHeader label="Ateliers de l'équipe – non partagés" count={teamCollabSessions.length} />
|
||||
<SectionHeader
|
||||
label="Ateliers de l'équipe – non partagés"
|
||||
count={teamCollabSessions.length}
|
||||
/>
|
||||
<p className="text-sm text-muted mb-5 -mt-2">
|
||||
En tant qu'admin d'équipe, vous voyez les ateliers de vos collaborateurs
|
||||
qui ne vous sont pas encore partagés.
|
||||
@@ -419,10 +651,8 @@ export function WorkshopTabs({
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
|
||||
|
||||
) : (
|
||||
/* ── Vue normale (tous / par type) ─────────────────────── */
|
||||
<div className="space-y-10">
|
||||
@@ -444,6 +674,30 @@ export function WorkshopTabs({
|
||||
<SessionsGrid sessions={teamCollabFiltered} view={cardView} isTeamCollab />
|
||||
</section>
|
||||
)}
|
||||
{/* Charger plus – visible pour les onglets par type uniquement */}
|
||||
{activeTab !== 'all' && totals && totals[activeTab as WorkshopTypeId] !== undefined && (
|
||||
(() => {
|
||||
const typeId = activeTab as WorkshopTypeId;
|
||||
const total = totals[typeId];
|
||||
const loaded = sessionsByType[typeId].length;
|
||||
if (loaded >= total) return null;
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 pt-2">
|
||||
<p className="text-sm text-muted">
|
||||
{loaded} sur {total} atelier{total > 1 ? 's' : ''}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => handleLoadMore(typeId)}
|
||||
className="px-5 py-2 rounded-full text-sm font-medium bg-card border border-border text-foreground/70 hover:text-foreground hover:bg-card-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Chargement…' : `Charger plus (${total - loaded} restants)`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||
import { getSessionById } from '@/services/sessions';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import { SwotBoard } from '@/components/swot/SwotBoard';
|
||||
import { SessionLiveWrapper } from '@/components/collaboration';
|
||||
import { EditableSessionTitle } from '@/components/ui';
|
||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface SessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -32,49 +29,20 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||
<Link href={getSessionsTabUrl('swot')} className="hover:text-foreground">
|
||||
{getWorkshop('swot').labelShort}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<EditableSessionTitle
|
||||
<SessionPageHeader
|
||||
workshopType="swot"
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
collaborator={session.resolvedCollaborator}
|
||||
size="lg"
|
||||
showEmail
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
badges={<>
|
||||
<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 */}
|
||||
<SessionLiveWrapper
|
||||
|
||||
32
src/app/sessions/loading.tsx
Normal file
32
src/app/sessions/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@/services/gif-mood';
|
||||
import { Card, PageHeader } from '@/components/ui';
|
||||
import { withWorkshopType } from '@/lib/workshops';
|
||||
import { SESSIONS_PAGE_SIZE } from '@/lib/types';
|
||||
import { WorkshopTabs } from './WorkshopTabs';
|
||||
import { NewWorkshopDropdown } from './NewWorkshopDropdown';
|
||||
|
||||
@@ -84,13 +85,23 @@ export default async function SessionsPage() {
|
||||
getTeamGifMoodSessions(session.user.id),
|
||||
]);
|
||||
|
||||
// Add workshopType to each session for unified display
|
||||
const allSwotSessions = withWorkshopType(swotSessions, 'swot');
|
||||
const allMotivatorSessions = withWorkshopType(motivatorSessions, 'motivators');
|
||||
const allYearReviewSessions = withWorkshopType(yearReviewSessions, 'year-review');
|
||||
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin');
|
||||
const allWeatherSessions = withWorkshopType(weatherSessions, 'weather');
|
||||
const allGifMoodSessions = withWorkshopType(gifMoodSessions, 'gif-mood');
|
||||
// Track totals before slicing for pagination UI
|
||||
const totals = {
|
||||
swot: swotSessions.length,
|
||||
motivators: motivatorSessions.length,
|
||||
'year-review': yearReviewSessions.length,
|
||||
'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 teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
|
||||
@@ -150,6 +161,7 @@ export default async function SessionsPage() {
|
||||
weeklyCheckInSessions={allWeeklyCheckInSessions}
|
||||
weatherSessions={allWeatherSessions}
|
||||
gifMoodSessions={allGifMoodSessions}
|
||||
totals={totals}
|
||||
teamCollabSessions={[
|
||||
...teamSwotWithType,
|
||||
...teamMotivatorWithType,
|
||||
|
||||
@@ -83,6 +83,15 @@ 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[];
|
||||
@@ -91,4 +100,5 @@ export interface WorkshopTabsProps {
|
||||
weatherSessions: WeatherSession[];
|
||||
gifMoodSessions: GifMoodSession[];
|
||||
teamCollabSessions?: (AnySession & { isTeamCollab?: true })[];
|
||||
totals?: WorkshopSessionTotals;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { getTeamOKRs } from '@/services/okrs';
|
||||
import { TeamDetailClient } from '@/components/teams/TeamDetailClient';
|
||||
import { DeleteTeamButton } from '@/components/teams/DeleteTeamButton';
|
||||
import { OKRsList } from '@/components/okrs';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Card } from '@/components/ui';
|
||||
import { Button, Card, PageHeader } from '@/components/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { TeamMember } from '@/lib/types';
|
||||
|
||||
@@ -40,33 +39,28 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Link href="/teams" className="text-muted hover:text-foreground">
|
||||
<div className="mb-2">
|
||||
<Link href="/teams" className="text-sm text-muted hover:text-foreground">
|
||||
← Retour aux équipes
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<span className="text-3xl">👥</span>
|
||||
{team.name}
|
||||
</h1>
|
||||
{team.description && <p className="mt-2 text-muted">{team.description}</p>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<PageHeader
|
||||
emoji="👥"
|
||||
title={team.name}
|
||||
subtitle={team.description ?? undefined}
|
||||
actions={
|
||||
isAdmin ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<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
|
||||
</Button>
|
||||
</Link>
|
||||
<DeleteTeamButton teamId={id} teamName={team.name} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Members Section */}
|
||||
<Card className="mb-8 p-6">
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function NewTeamPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
variant="brand"
|
||||
>
|
||||
{submitting ? 'Création...' : "Créer l'équipe"}
|
||||
</Button>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default async function TeamsPage() {
|
||||
subtitle={`${teams.length} équipe${teams.length !== 1 ? 's' : ''} · Collaborez et définissez vos OKRs`}
|
||||
actions={
|
||||
<Link href="/teams/new">
|
||||
<Button className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent">
|
||||
<Button variant="brand" size="sm">
|
||||
Créer une équipe
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -44,7 +44,7 @@ export default async function TeamsPage() {
|
||||
Créez votre première équipe pour commencer à définir des OKRs
|
||||
</div>
|
||||
<Link href="/teams/new" className="mt-6">
|
||||
<Button className="!bg-[var(--purple)] !text-white hover:!bg-[var(--purple)]/90">
|
||||
<Button variant="brand">
|
||||
Créer une équipe
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
42
src/app/users/loading.tsx
Normal file
42
src/app/users/loading.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
export default function UsersLoading() {
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-4">
|
||||
{/* PageHeader skeleton */}
|
||||
<div className="py-6 flex items-start gap-3">
|
||||
<div className="h-10 w-10 bg-card rounded-xl animate-pulse" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-7 w-36 bg-card rounded animate-pulse" />
|
||||
<div className="h-4 w-72 bg-card rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid skeleton */}
|
||||
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-border bg-card p-4 space-y-2 animate-pulse">
|
||||
<div className="h-8 w-12 bg-muted/40 rounded" />
|
||||
<div className="h-4 w-24 bg-muted/30 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* User rows skeleton */}
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 animate-pulse">
|
||||
<div className="h-12 w-12 rounded-full bg-muted/40 flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
<div className="h-4 w-32 bg-muted/40 rounded" />
|
||||
<div className="h-3 w-48 bg-muted/30 rounded" />
|
||||
</div>
|
||||
<div className="hidden sm:flex gap-2">
|
||||
<div className="h-6 w-16 bg-muted/30 rounded-full" />
|
||||
<div className="h-6 w-16 bg-muted/30 rounded-full" />
|
||||
<div className="h-6 w-16 bg-muted/30 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,31 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getAllUsersWithStats } from '@/services/auth';
|
||||
import { getAllUsersWithStats, type UserStats } from '@/services/auth';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
|
||||
const OWNED_WORKSHOP_COUNT_KEYS = [
|
||||
'sessions',
|
||||
'motivatorSessions',
|
||||
'yearReviewSessions',
|
||||
'weeklyCheckInSessions',
|
||||
'weatherSessions',
|
||||
'gifMoodSessions',
|
||||
] as const satisfies readonly (keyof UserStats)[];
|
||||
|
||||
const SHARED_WORKSHOP_COUNT_KEYS = [
|
||||
'sharedSessions',
|
||||
'sharedMotivatorSessions',
|
||||
'sharedYearReviewSessions',
|
||||
'sharedWeeklyCheckInSessions',
|
||||
'sharedWeatherSessions',
|
||||
'sharedGifMoodSessions',
|
||||
] as const satisfies readonly (keyof UserStats)[];
|
||||
|
||||
function sumCountKeys(counts: UserStats, keys: readonly (keyof UserStats)[]): number {
|
||||
return keys.reduce((acc, key) => acc + (counts[key] ?? 0), 0);
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
@@ -28,7 +50,11 @@ export default async function UsersPage() {
|
||||
|
||||
// Calculate some global stats
|
||||
const totalSessions = users.reduce(
|
||||
(acc, u) => acc + u._count.sessions + u._count.motivatorSessions,
|
||||
(acc, u) => acc + sumCountKeys(u._count, OWNED_WORKSHOP_COUNT_KEYS),
|
||||
0
|
||||
);
|
||||
const totalSharedSessions = users.reduce(
|
||||
(acc, u) => acc + sumCountKeys(u._count, SHARED_WORKSHOP_COUNT_KEYS),
|
||||
0
|
||||
);
|
||||
const avgSessionsPerUser = users.length > 0 ? totalSessions / users.length : 0;
|
||||
@@ -56,12 +82,7 @@ export default async function UsersPage() {
|
||||
<div className="text-sm text-muted">Moy. par user</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="text-2xl font-bold text-accent">
|
||||
{users.reduce(
|
||||
(acc, u) => acc + u._count.sharedSessions + u._count.sharedMotivatorSessions,
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-accent">{totalSharedSessions}</div>
|
||||
<div className="text-sm text-muted">Partages actifs</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,8 +90,8 @@ export default async function UsersPage() {
|
||||
{/* Users List */}
|
||||
<div className="space-y-3">
|
||||
{users.map((user) => {
|
||||
const totalUserSessions = user._count.sessions + user._count.motivatorSessions;
|
||||
const totalShares = user._count.sharedSessions + user._count.sharedMotivatorSessions;
|
||||
const totalUserSessions = sumCountKeys(user._count, OWNED_WORKSHOP_COUNT_KEYS);
|
||||
const totalShares = sumCountKeys(user._count, SHARED_WORKSHOP_COUNT_KEYS);
|
||||
const isCurrentUser = user.id === session.user?.id;
|
||||
|
||||
return (
|
||||
@@ -115,7 +136,7 @@ export default async function UsersPage() {
|
||||
backgroundColor: 'color-mix(in srgb, var(--strength) 15%, transparent)',
|
||||
color: 'var(--strength)',
|
||||
}}
|
||||
title="Sessions SWOT"
|
||||
title="Ateliers créés (tous types)"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
@@ -130,30 +151,7 @@ export default async function UsersPage() {
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
{user._count.sessions}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--opportunity) 15%, transparent)',
|
||||
color: 'var(--opportunity)',
|
||||
}}
|
||||
title="Sessions Moving Motivators"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
{user._count.motivatorSessions}
|
||||
{totalUserSessions}
|
||||
</div>
|
||||
{totalShares > 0 && (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||
import {
|
||||
getWeatherSessionById,
|
||||
getPreviousWeatherEntriesForUsers,
|
||||
@@ -15,8 +13,7 @@ import {
|
||||
WeatherAverageBar,
|
||||
WeatherTrendChart,
|
||||
} from '@/components/weather';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface WeatherSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -43,50 +40,29 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
|
||||
getUserTeams(authSession.user.id),
|
||||
getWeatherSessionsHistory(authSession.user.id),
|
||||
]);
|
||||
const currentHistoryIndex = history.findIndex((point) => point.sessionId === session.id);
|
||||
const previousTeamAverages =
|
||||
currentHistoryIndex > 0
|
||||
? {
|
||||
performance: history[currentHistoryIndex - 1].performance,
|
||||
moral: history[currentHistoryIndex - 1].moral,
|
||||
flux: history[currentHistoryIndex - 1].flux,
|
||||
valueCreation: history[currentHistoryIndex - 1].valueCreation,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||
<Link href={getSessionsTabUrl('weather')} className="hover:text-foreground">
|
||||
{getWorkshop('weather').labelShort}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<EditableWeatherTitle
|
||||
<SessionPageHeader
|
||||
workshopType="weather"
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
badges={<Badge variant="primary">{session.entries.length} membres</Badge>}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">{session.entries.length} membres</Badge>
|
||||
<span className="text-sm text-muted">
|
||||
{new Date(session.date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info sur les catégories */}
|
||||
<WeatherInfoPanel />
|
||||
|
||||
{/* Évolution dans le temps */}
|
||||
<WeatherTrendChart data={history} currentSessionId={session.id} />
|
||||
|
||||
{/* Live Wrapper + Board */}
|
||||
<WeatherLiveWrapper
|
||||
@@ -98,7 +74,7 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
|
||||
canEdit={session.canEdit}
|
||||
userTeams={userTeams}
|
||||
>
|
||||
<WeatherAverageBar entries={session.entries} />
|
||||
<WeatherAverageBar entries={session.entries} previousAverages={previousTeamAverages} />
|
||||
<WeatherBoard
|
||||
sessionId={session.id}
|
||||
currentUserId={authSession.user.id}
|
||||
@@ -112,6 +88,8 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
|
||||
canEdit={session.canEdit}
|
||||
previousEntries={Object.fromEntries(previousEntries)}
|
||||
/>
|
||||
<WeatherInfoPanel className="mt-6 mb-6" />
|
||||
<WeatherTrendChart data={history} currentSessionId={session.id} />
|
||||
</WeatherLiveWrapper>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
DateInput,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { createWeatherSession } from '@/actions/weather';
|
||||
@@ -93,20 +94,14 @@ export default function NewWeatherPage() {
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
|
||||
Date de la météo
|
||||
</label>
|
||||
<input
|
||||
<DateInput
|
||||
id="date"
|
||||
name="date"
|
||||
type="date"
|
||||
label="Date de la météo"
|
||||
value={selectedDate}
|
||||
onChange={handleDateChange}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card-hover p-4">
|
||||
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||
import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import type { ResolvedCollaborator } from '@/services/auth';
|
||||
@@ -9,8 +7,7 @@ import { getUserOKRsForPeriod } from '@/services/okrs';
|
||||
import { getCurrentQuarterPeriod } from '@/lib/okr-utils';
|
||||
import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin';
|
||||
import { CurrentQuarterOKRs } from '@/components/weekly-checkin/CurrentQuarterOKRs';
|
||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||
import { EditableWeeklyCheckInTitle } from '@/components/ui';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface WeeklyCheckInSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -48,44 +45,17 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||
<Link href={getSessionsTabUrl('weekly-checkin')} className="hover:text-foreground">
|
||||
{getWorkshop('weekly-checkin').label}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<EditableWeeklyCheckInTitle
|
||||
<SessionPageHeader
|
||||
workshopType="weekly-checkin"
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
ownerUser={session.user}
|
||||
date={session.date}
|
||||
collaborator={resolvedParticipant}
|
||||
badges={<Badge variant="primary">{session.items.length} items</Badge>}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay collaborator={resolvedParticipant} size="lg" showEmail />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">{session.items.length} items</Badge>
|
||||
<span className="text-sm text-muted">
|
||||
{new Date(session.date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Quarter OKRs - editable by participant or team admin */}
|
||||
{currentQuarterOKRs.length > 0 && (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
DateInput,
|
||||
Input,
|
||||
ParticipantInput,
|
||||
} from '@/components/ui';
|
||||
@@ -98,20 +99,14 @@ export default function NewWeeklyCheckInPage() {
|
||||
|
||||
<ParticipantInput name="participant" required />
|
||||
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
|
||||
Date du check-in
|
||||
</label>
|
||||
<input
|
||||
<DateInput
|
||||
id="date"
|
||||
name="date"
|
||||
type="date"
|
||||
label="Date du check-in"
|
||||
value={selectedDate}
|
||||
onChange={handleDateChange}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card-hover p-4">
|
||||
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
||||
import { getYearReviewSessionById } from '@/services/year-review';
|
||||
import { getUserTeams } from '@/services/teams';
|
||||
import type { ResolvedCollaborator } from '@/services/auth';
|
||||
import { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review';
|
||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||
import { EditableYearReviewTitle } from '@/components/ui';
|
||||
import { Badge, SessionPageHeader } from '@/components/ui';
|
||||
|
||||
interface YearReviewSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -32,49 +29,20 @@ export default async function YearReviewSessionPage({ params }: YearReviewSessio
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||
<Link href={getSessionsTabUrl('year-review')} className="hover:text-foreground">
|
||||
{getWorkshop('year-review').label}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<EditableYearReviewTitle
|
||||
<SessionPageHeader
|
||||
workshopType="year-review"
|
||||
sessionId={session.id}
|
||||
initialTitle={session.title}
|
||||
sessionTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay
|
||||
ownerUser={session.user}
|
||||
date={session.updatedAt}
|
||||
collaborator={session.resolvedParticipant as ResolvedCollaborator}
|
||||
size="lg"
|
||||
showEmail
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
badges={<>
|
||||
<Badge variant="primary">{session.items.length} items</Badge>
|
||||
<Badge variant="default">Année {session.year}</Badge>
|
||||
<span className="text-sm text-muted">
|
||||
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
/>
|
||||
|
||||
{/* Live Wrapper + Board */}
|
||||
<YearReviewLiveWrapper
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
NumberInput,
|
||||
Input,
|
||||
ParticipantInput,
|
||||
} from '@/components/ui';
|
||||
@@ -79,21 +80,15 @@ export default function NewYearReviewPage() {
|
||||
|
||||
<ParticipantInput name="participant" required />
|
||||
|
||||
<div>
|
||||
<label htmlFor="year" className="block text-sm font-medium text-foreground mb-1">
|
||||
Année du bilan
|
||||
</label>
|
||||
<input
|
||||
<NumberInput
|
||||
id="year"
|
||||
name="year"
|
||||
type="number"
|
||||
label="Année du bilan"
|
||||
min="2000"
|
||||
max="2100"
|
||||
defaultValue={currentYear}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card-hover p-4">
|
||||
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useLive, type LiveEvent } from '@/hooks/useLive';
|
||||
import { CollaborationToolbar } from './CollaborationToolbar';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import type { ShareRole } from '@prisma/client';
|
||||
|
||||
const ShareModal = dynamic(() => import('./ShareModal').then((m) => m.ShareModal), { ssr: false });
|
||||
import type { TeamWithMembers, Share } from '@/lib/share-utils';
|
||||
|
||||
export type LiveApiPath = 'sessions' | 'motivators' | 'weather' | 'year-review' | 'weekly-checkin' | 'gif-mood';
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import {
|
||||
Modal,
|
||||
Input,
|
||||
Button,
|
||||
Badge,
|
||||
Avatar,
|
||||
Select,
|
||||
SegmentedControl,
|
||||
IconButton,
|
||||
IconTrash,
|
||||
} from '@/components/ui';
|
||||
import { getTeamMembersForShare, type TeamWithMembers, type Share } from '@/lib/share-utils';
|
||||
import type { ShareRole } from '@prisma/client';
|
||||
|
||||
@@ -117,24 +122,20 @@ export function ShareModal({
|
||||
|
||||
{isOwner && (
|
||||
<form onSubmit={handleShare} className="space-y-4">
|
||||
<div className="flex gap-2 border-b border-border pb-3 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShareType(tab.value);
|
||||
<div className="border-b border-border pb-3">
|
||||
<SegmentedControl
|
||||
value={shareType}
|
||||
onChange={(value) => {
|
||||
setShareType(value);
|
||||
resetForm();
|
||||
}}
|
||||
className={`flex-1 min-w-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
shareType === tab.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card-hover text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
fullWidth
|
||||
className="flex w-full gap-2 border-0 bg-transparent p-0"
|
||||
options={tabs.map((tab) => ({
|
||||
value: tab.value,
|
||||
label: `${tab.icon} ${tab.label}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shareType === 'email' && (
|
||||
@@ -271,25 +272,13 @@ export function ShareModal({
|
||||
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||
</Badge>
|
||||
{isOwner && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={<IconTrash className="h-4 w-4" />}
|
||||
label="Retirer l'accès"
|
||||
variant="destructive"
|
||||
onClick={() => handleRemove(share.user.id)}
|
||||
disabled={isPending}
|
||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Retirer l'accès"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useState, useTransition } from 'react';
|
||||
import { updateGifMoodItem, deleteGifMoodItem } from '@/actions/gif-mood';
|
||||
import { IconClose } from '@/components/ui';
|
||||
|
||||
interface GifMoodCardProps {
|
||||
sessionId: string;
|
||||
@@ -83,9 +84,7 @@ export const GifMoodCard = memo(function GifMoodCard({
|
||||
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 hover:bg-black/70 transition-all backdrop-blur-sm"
|
||||
title="Supprimer ce GIF"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<IconClose className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { RocketIcon } from '@/components/ui';
|
||||
import { RocketIcon, getButtonClassName } from '@/components/ui';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { WorkshopsDropdown } from './WorkshopsDropdown';
|
||||
@@ -36,7 +36,7 @@ export async function Header() {
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-hover"
|
||||
className={getButtonClassName({ size: 'sm' })}
|
||||
>
|
||||
Connexion
|
||||
</Link>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
export function NavLinks() {
|
||||
const pathname = usePathname();
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
const isActiveLink = (path: string) => pathname.startsWith(path);
|
||||
|
||||
@@ -38,6 +39,17 @@ export function NavLinks() {
|
||||
>
|
||||
👥 Équipes
|
||||
</Link>
|
||||
|
||||
{isDev && (
|
||||
<Link
|
||||
href="/design-system"
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActiveLink('/design-system') ? 'text-primary' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
🎨 UI Guide
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Avatar } from '@/components/ui';
|
||||
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||
import { Avatar, DropdownMenu } from '@/components/ui';
|
||||
|
||||
interface UserMenuProps {
|
||||
userName: string | null | undefined;
|
||||
@@ -12,14 +10,12 @@ interface UserMenuProps {
|
||||
}
|
||||
|
||||
export function UserMenu({ userName, userEmail }: UserMenuProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(userMenuRef, () => setMenuOpen(false), menuOpen);
|
||||
|
||||
return (
|
||||
<div ref={userMenuRef} className="relative">
|
||||
<DropdownMenu
|
||||
panelClassName="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
onClick={toggle}
|
||||
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-card pl-1.5 pr-3 transition-colors hover:bg-card-hover"
|
||||
>
|
||||
<Avatar email={userEmail} name={userName} size={24} />
|
||||
@@ -27,36 +23,32 @@ export function UserMenu({ userName, userEmail }: UserMenuProps) {
|
||||
{userName || userEmail.split('@')[0]}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 text-muted transition-transform ${menuOpen ? 'rotate-180' : ''}`}
|
||||
className={`h-4 w-4 text-muted transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<p className="text-xs text-muted">Connecté en tant que</p>
|
||||
<p className="truncate text-sm font-medium text-foreground">{userEmail}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
onClick={close}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||
>
|
||||
👤 Mon Profil
|
||||
</Link>
|
||||
<Link
|
||||
href="/users"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
onClick={close}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||
>
|
||||
👥 Utilisateurs
|
||||
@@ -67,8 +59,8 @@ export function UserMenu({ userName, userEmail }: UserMenuProps) {
|
||||
>
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,20 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState, useRef } from 'react';
|
||||
import { DropdownMenu } from '@/components/ui';
|
||||
import { WORKSHOPS } from '@/lib/workshops';
|
||||
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||
|
||||
export function WorkshopsDropdown() {
|
||||
const [workshopsOpen, setWorkshopsOpen] = useState(false);
|
||||
const workshopsDropdownRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(workshopsDropdownRef, () => setWorkshopsOpen(false), workshopsOpen);
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActiveLink = (path: string) => pathname.startsWith(path);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={workshopsDropdownRef}>
|
||||
<DropdownMenu
|
||||
panelClassName="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
onClick={() => setWorkshopsOpen(!workshopsOpen)}
|
||||
onClick={toggle}
|
||||
className={`flex items-center gap-1 text-sm font-medium transition-colors ${
|
||||
WORKSHOPS.some((w) => isActiveLink(w.path))
|
||||
? 'text-primary'
|
||||
@@ -26,28 +24,24 @@ export function WorkshopsDropdown() {
|
||||
>
|
||||
Nouvel atelier
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
|
||||
className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
</button>
|
||||
|
||||
{workshopsOpen && (
|
||||
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{WORKSHOPS.map((w) => (
|
||||
<Link
|
||||
key={w.id}
|
||||
href={w.newPath}
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
|
||||
onClick={() => setWorkshopsOpen(false)}
|
||||
onClick={close}
|
||||
>
|
||||
<span className="text-lg">{w.icon}</span>
|
||||
<div>
|
||||
@@ -56,8 +50,8 @@ export function WorkshopsDropdown() {
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
|
||||
import { Button } from '@/components/ui';
|
||||
import { MotivatorCard } from './MotivatorCard';
|
||||
import { MotivatorSummary } from './MotivatorSummary';
|
||||
import { InfluenceZone } from './InfluenceZone';
|
||||
@@ -167,12 +168,9 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
|
||||
|
||||
{/* Next button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={nextStep}
|
||||
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Button onClick={nextStep} className="px-6">
|
||||
Suivant →
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -197,18 +195,12 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
|
||||
>
|
||||
<Button onClick={prevStep} variant="outline" className="px-6">
|
||||
← Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={nextStep}
|
||||
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={nextStep} className="px-6">
|
||||
Voir le récapitulatif →
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -226,12 +218,9 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-start">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
|
||||
>
|
||||
<Button onClick={prevStep} variant="outline" className="px-6">
|
||||
← Modifier
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, IconButton, IconTrash } from '@/components/ui';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import type { OKR, KeyResult, OKRStatus, KeyResultStatus } from '@/lib/types';
|
||||
import { OKR_STATUS_LABELS, KEY_RESULT_STATUS_LABELS } from '@/lib/types';
|
||||
@@ -143,32 +142,20 @@ export function OKRCard({ okr, teamId, isAdmin = false, compact = false }: OKRCa
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 relative z-10">
|
||||
{isAdmin && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={<IconTrash className="h-3 w-3" />}
|
||||
label="Supprimer l'OKR"
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
onClick={handleDelete}
|
||||
className="h-5 w-5 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
color: 'var(--destructive)',
|
||||
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
|
||||
}}
|
||||
disabled={isPending}
|
||||
title="Supprimer l'OKR"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<Badge
|
||||
style={{
|
||||
@@ -255,32 +242,20 @@ export function OKRCard({ okr, teamId, isAdmin = false, compact = false }: OKRCa
|
||||
{/* Action Zone */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 relative z-10">
|
||||
{isAdmin && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={<IconTrash className="h-4 w-4" />}
|
||||
label="Supprimer l'OKR"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="h-6 w-6 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
color: 'var(--destructive)',
|
||||
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
|
||||
}}
|
||||
disabled={isPending}
|
||||
title="Supprimer l'OKR"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<Badge
|
||||
style={{
|
||||
|
||||
@@ -478,7 +478,7 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
|
||||
variant="brand"
|
||||
>
|
||||
{submitting
|
||||
? initialData?.teamMemberId
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
|
||||
import { Button, Badge, Modal, ModalFooter, Input, Textarea, Select } from '@/components/ui';
|
||||
import {
|
||||
Button,
|
||||
Badge,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
IconButton,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
} from '@/components/ui';
|
||||
import { createAction, updateAction, deleteAction } from '@/actions/swot';
|
||||
|
||||
type ActionWithLinks = Action & {
|
||||
@@ -202,44 +213,19 @@ export function ActionPanel({
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-medium text-foreground line-clamp-2">{action.title}</h3>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
<IconButton
|
||||
icon={<IconEdit />}
|
||||
label="Modifier"
|
||||
onClick={() => openEditModal(action)}
|
||||
className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-card-hover hover:text-foreground group-hover:opacity-100"
|
||||
aria-label="Modifier"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
className="opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
<IconButton
|
||||
icon={<IconTrash />}
|
||||
label="Supprimer"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(action.id)}
|
||||
className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
className="opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -377,12 +363,14 @@ export function ActionPanel({
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">Priorité</label>
|
||||
<div className="flex gap-2">
|
||||
{priorityLabels.map((label, index) => (
|
||||
<button
|
||||
<Button
|
||||
key={index}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPriority(index)}
|
||||
className={`
|
||||
flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors
|
||||
flex-1
|
||||
${
|
||||
priority === index
|
||||
? index === 2
|
||||
@@ -395,17 +383,17 @@ export function ActionPanel({
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={closeModal}>
|
||||
<Button type="button" variant="outline" size="sm" onClick={closeModal}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Button type="submit" size="sm" loading={isPending}>
|
||||
{editingAction ? 'Enregistrer' : "Créer l'action"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client'
|
||||
import { SwotQuadrant } from './SwotQuadrant';
|
||||
import { SwotCard } from './SwotCard';
|
||||
import { ActionPanel } from './ActionPanel';
|
||||
import { Button } from '@/components/ui';
|
||||
import { moveSwotItem } from '@/actions/swot';
|
||||
|
||||
type ActionWithLinks = Action & {
|
||||
@@ -94,12 +95,9 @@ export function SwotBoard({ sessionId, items, actions }: SwotBoardProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={exitLinkMode}
|
||||
className="rounded-lg border border-border bg-card px-4 py-2 text-sm font-medium hover:bg-card-hover"
|
||||
>
|
||||
<Button onClick={exitLinkMode} variant="outline" size="sm">
|
||||
Annuler
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { forwardRef, memo, useState, useTransition } from 'react';
|
||||
import type { SwotItem, SwotCategory } from '@prisma/client';
|
||||
import { updateSwotItem, deleteSwotItem, duplicateSwotItem } from '@/actions/swot';
|
||||
import { IconEdit, IconTrash, IconDuplicate, IconCheck, IconButton } from '@/components/ui';
|
||||
|
||||
interface SwotCardProps {
|
||||
item: SwotItem;
|
||||
@@ -113,72 +114,32 @@ export const SwotCard = memo(
|
||||
{/* Actions (visible on hover) */}
|
||||
{!linkMode && (
|
||||
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
<IconButton
|
||||
icon={<IconEdit />}
|
||||
label="Modifier"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
|
||||
aria-label="Modifier"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
<IconButton
|
||||
icon={<IconDuplicate />}
|
||||
label="Dupliquer"
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicate();
|
||||
}}
|
||||
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
|
||||
aria-label="Dupliquer"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
<IconButton
|
||||
icon={<IconTrash />}
|
||||
label="Supprimer"
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -187,9 +148,7 @@ export const SwotCard = memo(
|
||||
<div
|
||||
className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
<IconCheck />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user