Compare commits

..

7 Commits

100 changed files with 23157 additions and 8268 deletions

15
.browserslistrc Normal file
View File

@@ -0,0 +1,15 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.dev/reference/versions#browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
Chrome >=107
Firefox >=106
Edge >=107
Safari >=16.1
iOS >=16.1

16
.editorconfig Normal file
View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

47
.eslintrc.json Normal file
View File

@@ -0,0 +1,47 @@
{
"root": true,
"ignorePatterns": ["projects/**/*"],
"overrides": [
{
"files": ["*.ts"],
"parserOptions": {
"project": ["tsconfig.json"],
"createDefaultProgram": true
},
"extends": [
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/prefer-standalone": "off",
"@angular-eslint/component-class-suffix": [
"error",
{
"suffixes": ["Page", "Component"]
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@angular-eslint/template/recommended"],
"rules": {}
}
]
}

71
.gitignore vendored Normal file
View File

@@ -0,0 +1,71 @@
# Specifies intentionally untracked files to ignore when using Git
# http://git-scm.com/docs/gitignore
*~
*.sw[mnpcod]
.tmp
*.tmp
*.tmp.*
UserInterfaceState.xcuserstate
$RECYCLE.BIN/
*.log
log.txt
/.sourcemaps
/.versions
/coverage
# Ionic
/.ionic
/www
/platforms
/plugins
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-project
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular
/.angular/cache
.sass-cache/
/.nx
/.nx/cache
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
CLAUDE.md

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"Webnative.webnative"
]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"typescript.preferences.autoImportFileExcludePatterns": ["@ionic/angular/common", "@ionic/angular/standalone"]
}

600
README.md
View File

@@ -1,279 +1,347 @@
# P2P Poll App # P2P Survey App
A peer-to-peer polling application built with React, TypeScript, Tailwind CSS, Node.js, Yjs, and WebSocket for real-time collaborative voting. A serverless peer-to-peer survey application built with Ionic and Angular.
Survey creators store all data locally in their browser and participants connect
directly via WebRTC — no backend server required.
**Users can create polls, add answers, and vote in real-time with automatic P2P synchronization across all connected clients.** ---
## Table of Contents
1. [Overview](#overview)
2. [Architecture](#architecture)
3. [Features](#features)
4. [Setup & Installation](#setup--installation)
5. [How It Works](#how-it-works)
- [Creator Flow](#creator-flow)
- [Participant Flow](#participant-flow)
6. [Question Types](#question-types)
7. [Technology Stack](#technology-stack)
8. [Limitations](#limitations)
9. [Self-Hosting the PeerJS Signaling Server](#self-hosting-the-peerjs-signaling-server)
10. [Development Commands](#development-commands)
11. [Technology Choices](#technology-choices)
---
## Overview
P2P Survey App lets you create and distribute surveys without any server-side
infrastructure. All survey data — questions, participant tokens, and responses —
lives exclusively in the survey creator's browser (IndexedDB). Participants
connect directly to the creator's browser using WebRTC peer-to-peer data
channels, facilitated by the PeerJS library.
---
## Architecture ## Architecture
**Hybrid P2P Approach:** ```
- Backend serves as both a Yjs WebSocket provider (for state synchronization) and signaling server (for WebRTC peer discovery) Creator Browser (Host)
- Clients sync poll data via Yjs CRDT for conflict-free merging ┌─────────────────────────────────────────────┐
- Direct P2P connections via WebRTC for real-time updates when possible │ Ionic/Angular App │
- Server fallback ensures reliability when P2P fails │ ┌─────────────────┐ ┌───────────────────┐│
│ │ PeerService │ │ Dexie (IndexedDB)││
│ │ (PeerJS/WebRTC)│<─>│ surveys ││
│ └────────┬────────┘ │ participants ││
│ │ │ responses ││
│ Peer ID: survey-{id}└───────────────────┘│
└───────────┼─────────────────────────────────┘
[PeerJS Cloud signaling only, not data relay]
Participant Browser
┌─────────────────────────────────────────────┐
│ Opens: /participate?host=survey-{id} │
│ &token={uuid} │
│ │
│ 1. Connect to host peer via WebRTC │
│ 2. Send { type: 'join', token } │
│ 3. Receive survey questions │
│ 4. Fill in answers │
│ 5. Send { type: 'submit', answers } │
└─────────────────────────────────────────────┘
```
## Tech Stack ### Star topology
**Backend:** The survey creator acts as the central hub. Each participant opens a direct
- Node.js + TypeScript WebRTC data channel to the creator's browser. The creator's browser validates
- Express.js tokens, stores responses, and optionally pushes aggregated results back.
- WebSocket (ws)
- y-websocket (Yjs WebSocket provider)
- CORS support
**Frontend:** The PeerJS signaling server is only used for the initial WebRTC handshake
- React 18 + TypeScript (exchanging ICE candidates). Once connected, all data flows directly between
- Vite (build tool) the two browsers — the signaling server never sees response data.
- Tailwind CSS
- Yjs (CRDT library) ---
- y-websocket (server sync)
- y-webrtc (P2P sync) ## Features
- lucide-react (icons)
- **Create surveys** with four question types: free text, multiple choice,
yes/no, and 15 rating
- **Generate unique participant links** — each link contains a UUID token that
identifies one participant
- **Token-based access control** — each token can only be submitted once;
reuse is rejected by the host
- **Draft saving** — participants can save answers before final submission
without locking their token
- **Live results** — the creator sees responses update in real time using
Dexie's `liveQuery`
- **Optional results sharing** — the creator can toggle whether participants
see aggregated results after submitting
- **CSV export** — download all responses as a comma-separated file
- **Local-only storage** — all data lives in the creator's IndexedDB; delete
the survey and the data is gone
---
## Setup & Installation
### Prerequisites
- Node.js 18+ and npm 9+
- A modern browser (Chrome, Firefox, Edge, Safari)
### Install
```bash
# 1. Install Ionic CLI globally (skip if already installed)
npm install -g @ionic/cli
# 2. Install project dependencies
npm install
# 3. Start the development server
ionic serve
```
The app opens at `http://localhost:8100`.
### Build for production
```bash
ionic build --prod
```
Output is written to `www/`. Deploy the contents of `www/` to any static
web host (GitHub Pages, Netlify, Vercel, Nginx, etc.).
---
## How It Works
### Creator Flow
1. **Open the app** and click **Create Survey**.
2. **Enter a title** (required) and optional description.
3. **Add questions** — choose a type for each and mark which are required.
4. Click **Create** to save the survey to IndexedDB and navigate to the
**Survey Detail** page.
5. On the detail page:
- Toggle **Show results to participants** if desired.
- Enter a number and click **Generate Links** to create unique participant
tokens. Copy links to share.
- Click **Start Hosting** to initialise the PeerJS peer. The app listens
for incoming connections using the peer ID `survey-{surveyId}`.
6. **Keep this page or the Results page open** while collecting responses.
If the creator navigates away, the peer is destroyed and participants
cannot connect.
7. Click **View Results** to see live aggregated and individual responses.
8. Click **Export CSV** to download all responses.
### Participant Flow
1. **Open the unique link** shared by the survey creator.
2. The app parses `host` and `token` from the URL query parameters.
3. A WebRTC connection is established to the host's peer ID.
4. The participant sends `{ type: 'join', token }`.
5. The host validates the token:
- **Invalid token** → error card is shown.
- **Already submitted** → error card is shown (token is locked).
- **Valid** → survey questions are sent back.
6. Participant fills in answers. Answers are auto-saved as drafts after each
blur event (free text) or selection change (other types).
7. Click **Submit** to finalise. The token is locked on the host side.
8. If the creator enabled result sharing, aggregated results are shown.
---
## Question Types
| Type | Input widget | Aggregation |
|---|---|---|
| **Free Text** | Multi-line textarea | List of all answers |
| **Multiple Choice** | Radio buttons (creator-defined options) | Count per option + bar chart |
| **Yes / No** | Radio buttons (Yes / No) | Count per option + bar chart |
| **Rating** | 5 buttons (15) | Average score + distribution bar chart |
---
## Technology Stack
| Library | Version | Role |
|---|---|---|
| `@ionic/angular` | 8.x | Mobile-first UI components |
| `@angular/core` | 20.x | Application framework |
| `peerjs` | 1.5.x | WebRTC data channel wrapper |
| `dexie` | 4.x | IndexedDB wrapper with liveQuery |
| `qrcode` | 1.5.x | QR code generation (optional usage) |
---
## Limitations
### Host must be online
The survey creator's browser **must be open** and on the Survey Detail or
Results page for participants to submit responses. There is no server that
relays or buffers messages. If the creator goes offline, participants see a
"Host is Offline" card.
### Data loss
All data is stored in the creator's browser IndexedDB. Data is lost if:
- The creator explicitly deletes the survey
- The browser's storage is cleared (private/incognito mode, clearing site data)
- The browser's IndexedDB quota is exceeded
Export responses to CSV regularly to prevent data loss.
### PeerJS Cloud rate limits
The free PeerJS Cloud signaling server (`0.peerjs.com`) has rate limits and
may occasionally be unavailable. For production use, self-host the signaling
server (see below).
### NAT traversal
WebRTC uses STUN to traverse most NAT configurations. Strict corporate
firewalls or symmetric NAT may block direct connections. For full reliability
in such environments, add a TURN server to the PeerJS configuration in
`src/app/services/peer.service.ts`.
### Browser compatibility
Requires a browser that supports:
- WebRTC (DataChannels)
- IndexedDB
- `crypto.randomUUID()`
All modern browsers (Chrome 86+, Firefox 78+, Safari 15.4+, Edge 86+) satisfy
these requirements.
---
## Self-Hosting the PeerJS Signaling Server
For production deployments, run your own PeerJS server to avoid rate limits.
### Option 1: npm
```bash
npm install -g peer
peerjs --port 9000 --key peerjs
```
### Option 2: Docker
```bash
docker run -p 9000:9000 peerjs/peerjs-server
```
### Option 3: Node.js
```javascript
// server.js
const { PeerServer } = require('peer');
const server = PeerServer({ port: 9000, path: '/peerjs' });
```
### Configure the app to use your server
Edit `src/app/services/peer.service.ts` and update the `Peer` constructor:
```typescript
this.peer = new Peer(peerId, {
host: 'your-peer-server.example.com',
port: 9000,
path: '/peerjs',
secure: true, // use wss:// if your server has TLS
});
```
---
## Development Commands
```bash
# Start dev server with live reload
ionic serve
# Build for production
ionic build --prod
# Run TypeScript type check
npx tsc --noEmit
# Lint
npx ng lint
# Build Angular app only (no Ionic wrapper)
npx ng build
```
---
## Technology Choices
### PeerJS over Gun.js
Gun.js is a decentralized graph database that syncs between peers. However,
a survey has a **star topology** (one host, many participants) — not a mesh
where every peer is equal. PeerJS maps directly to this model: the creator
opens one peer ID, participants connect to it, and all data flows through
that central point. Token validation and response deduplication are trivial
to enforce at the host. Gun.js would require additional complexity to achieve
the same guarantees.
### Dexie.js over raw IndexedDB
Raw IndexedDB uses a callback-based API that is verbose and error-prone.
Dexie wraps it in clean Promises, adds a TypeScript-friendly schema
definition, and provides `liveQuery` — a reactive subscription mechanism
that automatically re-runs queries when the underlying data changes. This
powers the live results view without any manual polling or event wiring.
### Module-based Angular over Standalone Components
The Ionic CLI scaffolds a module-based project by default with Angular 20.
Module-based components provide a clear separation of concern via `NgModule`
declarations and are well-supported by the Ionic ecosystem.
---
## Project Structure ## Project Structure
``` ```
quicgroup/ src/
── server/ # Backend Node.js server ── app/
├── src/ ├── shared/
── index.ts # Main server entry ── models/
├── yjs-server.ts # Yjs WebSocket provider └── survey.models.ts # All TypeScript interfaces + P2PMessage type
│ │ ├── signaling-server.ts # WebRTC signaling ├── database/
── types/ # TypeScript types ── database.ts # Dexie singleton (AppDatabase)
│ └── utils/ # Utilities ├── services/
│ ├── package.json │ ├── peer.service.ts # PeerJS wrapper (NgZone-aware)
── tsconfig.json ── survey.service.ts # Survey/participant CRUD
│ └── response.service.ts # Response storage + aggregation
└── frontend/ # React frontend ├── pages/
├── src/ │ ├── home/ # Survey list
│ ├── components/ # React components │ ├── create-survey/ # Survey creation/editing
│ ├── hooks/ # Custom React hooks │ ├── survey-detail/ # Settings, link generation, hosting
│ ├── lib/ # Yjs setup and utilities │ ├── survey-results/ # Live results view
── types/ # TypeScript types ── participate/ # Participant survey form
│ └── styles/ # CSS styles ├── app-routing.module.ts
├── package.json ├── app.component.ts
└── vite.config.ts └── app.module.ts
``` ```
## Setup Instructions
### Prerequisites
- Node.js 18+ and npm
### Backend Setup
1. Navigate to server directory:
```bash
cd server
```
2. Install dependencies:
```bash
npm install
```
3. Copy environment file:
```bash
cp .env.example .env
```
4. Start the development server:
```bash
npm run dev
```
The server will run on `http://localhost:3000` with:
- Yjs WebSocket: `ws://localhost:3000/yjs`
- Signaling WebSocket: `ws://localhost:3000/signal`
### Frontend Setup
1. Navigate to frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Copy environment file (optional):
```bash
cp .env.example .env
```
4. Start the development server:
```bash
npm run dev
```
The frontend will run on `http://localhost:5173`
## Running the Application
1. **Start Backend** (Terminal 1):
```bash
cd server
npm run dev
```
2. **Start Frontend** (Terminal 2):
```bash
cd frontend
npm run dev
```
3. **Open Browser:**
- Navigate to `http://localhost:5173`
- Open multiple tabs/windows to test P2P synchronization
## Features
### Dynamic Poll Creation
- **Create Polls** - Any user can create new polls with custom questions
- **Add Answers** - Anyone can add answer options to any poll
- **Real-time Voting** - Vote on options with instant updates across all clients
- **Smart Vote Tracking** - One vote per user per option (prevents duplicate voting)
- **Visual Feedback** - Green border and " Voted" indicator on voted options
- **User Attribution** - See who created each poll and option
- **Live Vote Counts** - See vote percentages and counts update in real-time
- **P2P Synchronization** - Uses Yjs CRDT for conflict-free state merging
- **Connection Status** - Visual indicator showing WebSocket and peer connections
- **Hybrid Architecture** - Combines WebSocket server sync with WebRTC P2P
- **Beautiful UI** - Modern gradient design with Tailwind CSS
## How to Use
### Create a Poll
1. Enter your question in the "Create a New Poll" form at the top
2. Click "Create Poll"
3. Your poll appears instantly for all connected users
### Add Answer Options
1. Find the poll you want to add an answer to
2. Type your answer in the "Add a new option..." field
3. Click "Add"
4. Your answer appears instantly for all users
### Vote on Options
1. Click the vote button (thumbs up icon) on any option
2. You can only vote once per option
3. Voted options show a green border and " Voted" indicator
4. Vote counts update in real-time across all clients
### Multi-User Testing
1. Open multiple browser tabs/windows
2. Create polls from different tabs
3. Add answers from different tabs
4. Vote from different tabs
5. Watch real-time synchronization in action!
## How It Works
### CRDT Synchronization
The app uses Yjs (a CRDT library) to ensure all clients converge to the same state without conflicts:
- Each client maintains a local Yjs document
- Changes are automatically synced via WebSocket to the server
- WebRTC provides direct P2P connections between clients
- Yjs handles merge conflicts automatically
- One vote per user per option is enforced via `votedBy` tracking
## Data Model
```typescript
{
polls: Array<{
id: string,
question: string,
createdBy: string,
timestamp: number,
options: Array<{
id: string,
text: string,
votes: number,
votedBy: string[], // Tracks which users have voted
createdBy: string,
timestamp: number
}>
}>
}
```
## Testing P2P Functionality
1. Open the app in multiple browser tabs/windows
2. **Create polls** from different tabs - they appear everywhere instantly
3. **Add answer options** from different tabs to the same poll
4. **Vote** from different tabs - watch vote counts update in real-time
5. Try voting twice on the same option - it won't let you!
6. Check the connection status indicator for peer count
7. Verify visual feedback (green border) on options you've voted on
## Development
### Backend Scripts
- `npm run dev` - Start development server with hot reload
- `npm run build` - Build for production
- `npm start` - Run production build
### Frontend Scripts
- `npm run dev` - Start Vite dev server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
## Environment Variables
### Backend (.env)
```
PORT=3000
YJS_WS_PORT=1234
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
```
### Frontend (.env)
```
VITE_WS_URL=ws://localhost:3000
```
## Components
### Frontend Components
- **PollView** - Main view showing all polls and create poll form
- **CreatePoll** - Form to create new polls
- **PollCard** - Individual poll display with metadata
- **OptionList** - List of answer options with vote tracking
- **AddOption** - Form to add new answer options
- **VoteButton** - Vote button with disabled state for voted options
- **ConnectionStatus** - Shows WebSocket and P2P connection status
### Key Functions
- `createPoll(question)` - Create a new poll
- `addOption(pollId, text)` - Add an option to a specific poll
- `vote(pollId, optionId)` - Vote on an option (one vote per user)
- `hasVoted(option)` - Check if current user has voted on an option
## User Tracking
Each user gets a unique ID stored in localStorage:
- Format: `user-xxxxxxxxx`
- Used to track poll/option creators
- Used to prevent duplicate voting
- Persists across browser sessions
## Future Enhancements
- [ ] Edit/delete polls and options
- [ ] User nicknames instead of IDs
- [ ] Poll expiration/closing
- [ ] Vote history and analytics
- [ ] Export poll results
- [ ] Persistent storage (database)
- [ ] Dark mode toggle
- [ ] Mobile responsive improvements
- [ ] Poll categories/tags
- [ ] Search/filter polls
## License
MIT

170
angular.json Normal file
View File

@@ -0,0 +1,170 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"app": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "www",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
},
{
"glob": "**/*.svg",
"input": "node_modules/ionicons/dist/ionicons/svg",
"output": "./svg"
},
{
"glob": "serve.json",
"input": "src",
"output": "/"
},
{
"glob": "serve.json",
"input": "src",
"output": "/"
}
],
"styles": [
"src/global.scss",
"src/theme/variables.scss"
],
"scripts": [],
"allowedCommonJsDependencies": [
"sdp",
"webrtc-adapter"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"ci": {
"progress": false
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "app:build:production"
},
"development": {
"buildTarget": "app:build:development"
},
"ci": {
"progress": false
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "app:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
},
{
"glob": "**/*.svg",
"input": "node_modules/ionicons/dist/ionicons/svg",
"output": "./svg"
}
],
"styles": [
"src/global.scss",
"src/theme/variables.scss"
],
"scripts": []
},
"configurations": {
"ci": {
"progress": false,
"watch": false
}
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"cli": {
"schematicCollections": [
"@ionic/angular-toolkit"
],
"analytics": false
},
"schematics": {
"@ionic/angular-toolkit:component": {
"styleext": "scss"
},
"@ionic/angular-toolkit:page": {
"styleext": "scss"
}
}
}

9
capacitor.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'io.ionic.starter',
appName: 'IonicAngularVersion',
webDir: 'www'
};
export default config;

View File

@@ -1 +0,0 @@
VITE_WS_URL=ws://localhost:3000

6
frontend/.gitignore vendored
View File

@@ -1,6 +0,0 @@
node_modules
dist
dist-ssr
*.local
.env
.DS_Store

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>P2P Poll App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
{
"name": "p2p-poll-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"yjs": "^13.6.8",
"y-websocket": "^1.5.0",
"y-webrtc": "^10.2.5",
"lucide-react": "^0.294.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,8 +0,0 @@
import { PollView } from './components/PollView';
import './styles/index.css';
function App() {
return <PollView />;
}
export default App;

View File

@@ -1,38 +0,0 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
interface AddOptionProps {
onAdd: (text: string) => void;
}
export function AddOption({ onAdd }: AddOptionProps) {
const [text, setText] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim()) {
onAdd(text);
setText('');
}
};
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new option..."
className="flex-1 px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
/>
<button
type="submit"
disabled={!text.trim()}
className="px-6 py-3 bg-white text-purple-600 rounded-lg font-semibold hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Add
</button>
</form>
);
}

View File

@@ -1,34 +0,0 @@
import { Wifi, WifiOff, Users } from 'lucide-react';
interface ConnectionStatusProps {
isConnected: boolean;
wsProvider: any;
webrtcProvider: any;
}
export function ConnectionStatus({ isConnected, wsProvider, webrtcProvider }: ConnectionStatusProps) {
const peerCount = webrtcProvider?.room?.peers?.size || 0;
return (
<div className="flex items-center gap-4 text-white/90 text-sm">
<div className="flex items-center gap-2">
{isConnected ? (
<>
<Wifi className="w-4 h-4 text-green-400" />
<span>Connected</span>
</>
) : (
<>
<WifiOff className="w-4 h-4 text-red-400" />
<span>Disconnected</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{peerCount} peer{peerCount !== 1 ? 's' : ''}</span>
</div>
</div>
);
}

View File

@@ -1,43 +0,0 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
interface CreatePollProps {
onCreate: (question: string) => void;
}
export function CreatePoll({ onCreate }: CreatePollProps) {
const [question, setQuestion] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (question.trim()) {
onCreate(question);
setQuestion('');
}
};
return (
<form onSubmit={handleSubmit} className="mb-8">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-6 border border-white/20">
<h2 className="text-xl font-bold text-white mb-4">Create a New Poll</h2>
<div className="flex gap-2">
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Enter your question..."
className="flex-1 px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
/>
<button
type="submit"
disabled={!question.trim()}
className="px-6 py-3 bg-white text-purple-600 rounded-lg font-semibold hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Create Poll
</button>
</div>
</div>
</form>
);
}

View File

@@ -1,60 +0,0 @@
import { PollOption } from '../types/poll.types';
import { VoteButton } from './VoteButton';
interface OptionListProps {
options: PollOption[];
onVote: (optionId: string) => void;
hasVoted?: (option: PollOption) => boolean;
}
export function OptionList({ options, onVote, hasVoted }: OptionListProps) {
const totalVotes = options.reduce((sum, opt) => sum + opt.votes, 0);
const sortedOptions = [...options].sort((a, b) => b.votes - a.votes);
return (
<div className="space-y-3">
{sortedOptions.length === 0 ? (
<div className="text-center py-8 text-white/60">
No options yet. Add one to get started!
</div>
) : (
sortedOptions.map((option) => {
const percentage = totalVotes > 0 ? (option.votes / totalVotes) * 100 : 0;
const userHasVoted = hasVoted ? hasVoted(option) : false;
return (
<div
key={option.id}
className={`bg-white/10 backdrop-blur-sm rounded-lg p-4 border transition-all duration-200 ${
userHasVoted
? 'border-green-400/50 bg-green-400/10'
: 'border-white/20 hover:border-white/30'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-white font-medium text-lg">
{option.text}
{userHasVoted && <span className="ml-2 text-green-400 text-sm"> Voted</span>}
</span>
<VoteButton optionId={option.id} votes={option.votes} onVote={onVote} disabled={userHasVoted} />
</div>
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-green-400 to-blue-500 h-full transition-all duration-500 ease-out"
style={{ width: `${percentage}%` }}
/>
</div>
<div className="mt-2 flex justify-between text-xs text-white/60">
<span>by {option.createdBy}</span>
<span>{percentage.toFixed(1)}%</span>
</div>
</div>
);
})
)}
</div>
);
}

View File

@@ -1,50 +0,0 @@
import { Poll } from '../types/poll.types';
import { AddOption } from './AddOption';
import { OptionList } from './OptionList';
import { User, Clock } from 'lucide-react';
interface PollCardProps {
poll: Poll;
onAddOption: (pollId: string, text: string) => void;
onVote: (pollId: string, optionId: string) => void;
hasVoted: (option: any) => boolean;
}
export function PollCard({ poll, onAddOption, onVote, hasVoted }: PollCardProps) {
const handleAddOption = (text: string) => {
onAddOption(poll.id, text);
};
const handleVote = (optionId: string) => {
onVote(poll.id, optionId);
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
return (
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20 mb-6">
<div className="mb-6">
<h2 className="text-3xl font-bold text-white mb-3">{poll.question}</h2>
<div className="flex items-center gap-4 text-white/60 text-sm">
<div className="flex items-center gap-1">
<User className="w-4 h-4" />
<span>{poll.createdBy}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
<span>{formatTime(poll.timestamp)}</span>
</div>
</div>
</div>
<div className="mb-6">
<AddOption onAdd={handleAddOption} />
</div>
<OptionList options={poll.options} onVote={handleVote} hasVoted={hasVoted} />
</div>
);
}

View File

@@ -1,57 +0,0 @@
import { RefreshCw } from 'lucide-react';
import { usePoll } from '../hooks/usePoll';
import { CreatePoll } from './CreatePoll';
import { PollCard } from './PollCard';
import { ConnectionStatus } from './ConnectionStatus';
export function PollView() {
const { polls, createPoll, addOption, vote, hasVoted, isConnected, wsProvider, webrtcProvider } = usePoll();
return (
<div className="min-h-screen p-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold text-white mb-2">P2P Polling App</h1>
<p className="text-white/70">Create polls, add answers, and vote in real-time</p>
</div>
<ConnectionStatus
isConnected={isConnected}
wsProvider={wsProvider}
webrtcProvider={webrtcProvider}
/>
</div>
<CreatePoll onCreate={createPoll} />
{polls.length === 0 ? (
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-12 border border-white/20 text-center">
<p className="text-white/60 text-lg mb-2">No polls yet!</p>
<p className="text-white/40">Create the first poll to get started.</p>
</div>
) : (
<div className="space-y-6">
{polls.map((poll) => (
<PollCard
key={poll.id}
poll={poll}
onAddOption={addOption}
onVote={vote}
hasVoted={hasVoted}
/>
))}
</div>
)}
<div className="mt-8 text-center text-white/50 text-sm">
<p>QUIC P2P Experiment !</p>
<p className="flex items-center justify-center gap-2 mt-1">
<RefreshCw className="w-4 h-4" />
Real-time P2P synchronization with Yjs
</p>
<p className="mt-1">Open multiple tabs to see live updates!</p>
</div>
</div>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { ThumbsUp } from 'lucide-react';
interface VoteButtonProps {
optionId: string;
votes: number;
onVote: (optionId: string) => void;
disabled?: boolean;
}
export function VoteButton({ optionId, votes, onVote, disabled = false }: VoteButtonProps) {
return (
<button
onClick={() => !disabled && onVote(optionId)}
disabled={disabled}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-white font-medium ${
disabled
? 'bg-white/10 cursor-not-allowed opacity-60'
: 'bg-white/20 hover:bg-white/30'
}`}
>
<ThumbsUp className="w-4 h-4" />
<span>{votes}</span>
</button>
);
}

View File

@@ -1,37 +0,0 @@
import { useCallback } from 'react';
import { pollManager } from '../lib/poll-manager';
import { useYjsSync } from './useYjsSync';
export function usePoll() {
const { polls, isConnected, wsProvider, webrtcProvider } = useYjsSync();
const createPoll = useCallback((question: string) => {
return pollManager.createPoll(question);
}, []);
const addOption = useCallback((pollId: string, text: string) => {
pollManager.addOption(pollId, text);
}, []);
const vote = useCallback((pollId: string, optionId: string) => {
pollManager.vote(pollId, optionId);
}, []);
const hasVoted = useCallback((option: any) => {
return pollManager.hasVoted(option);
}, []);
const userId = pollManager.getUserId();
return {
polls,
createPoll,
addOption,
vote,
hasVoted,
userId,
isConnected,
wsProvider,
webrtcProvider
};
}

View File

@@ -1,44 +0,0 @@
import { useEffect, useState } from 'react';
import { Poll } from '../types/poll.types';
import {
initializeProviders,
destroyProviders,
yPolls,
wsProvider,
webrtcProvider
} from '../lib/yjs-setup';
export function useYjsSync() {
const [polls, setPolls] = useState<Poll[]>([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const { wsProvider: ws } = initializeProviders();
const updatePolls = () => {
setPolls([...yPolls.toArray()]);
};
yPolls.observe(updatePolls);
updatePolls();
const handleStatus = (event: { status: string }) => {
setIsConnected(event.status === 'connected');
};
ws?.on('status', handleStatus);
return () => {
yPolls.unobserve(updatePolls);
ws?.off('status', handleStatus);
destroyProviders();
};
}, []);
return {
polls,
isConnected,
wsProvider,
webrtcProvider
};
}

View File

@@ -1,43 +0,0 @@
import { PollOption } from '../types/poll.types';
import { createPoll as yjsCreatePoll, addOption as yjsAddOption, voteForOption as yjsVoteForOption } from './yjs-setup';
export class PollManager {
private userId: string;
constructor() {
this.userId = this.generateUserId();
}
private generateUserId(): string {
const stored = localStorage.getItem('p2p-poll-user-id');
if (stored) return stored;
const newId = 'user-' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('p2p-poll-user-id', newId);
return newId;
}
getUserId(): string {
return this.userId;
}
createPoll(question: string): string {
if (!question.trim()) return '';
return yjsCreatePoll(question.trim(), this.userId);
}
addOption(pollId: string, text: string): void {
if (!text.trim()) return;
yjsAddOption(pollId, text.trim(), this.userId);
}
vote(pollId: string, optionId: string): void {
yjsVoteForOption(pollId, optionId, this.userId);
}
hasVoted(option: PollOption): boolean {
return option.votedBy.includes(this.userId);
}
}
export const pollManager = new PollManager();

View File

@@ -1,133 +0,0 @@
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { WebrtcProvider } from 'y-webrtc';
import { Poll, PollOption } from '../types/poll.types';
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:3000';
const ROOM_NAME = 'default-poll';
export const ydoc = new Y.Doc();
export const yPolls = ydoc.getArray<Poll>('polls');
export let wsProvider: WebsocketProvider | null = null;
export let webrtcProvider: WebrtcProvider | null = null;
export function initializeProviders() {
wsProvider = new WebsocketProvider(
WS_URL + '/yjs',
ROOM_NAME,
ydoc,
{ connect: true }
);
webrtcProvider = new WebrtcProvider(
ROOM_NAME,
ydoc,
{
signaling: [WS_URL.replace('ws://', 'wss://').replace('http://', 'https://') + '/signal'],
password: null,
awareness: wsProvider.awareness,
maxConns: 20,
filterBcConns: true,
peerOpts: {}
}
);
wsProvider.on('status', (event: { status: string }) => {
console.log('WebSocket status:', event.status);
});
webrtcProvider.on('synced', (synced: boolean) => {
console.log('WebRTC synced:', synced);
});
return { wsProvider, webrtcProvider };
}
export function destroyProviders() {
wsProvider?.destroy();
webrtcProvider?.destroy();
}
export function createPoll(question: string, createdBy: string): string {
const pollId = Math.random().toString(36).substr(2, 9);
const poll: Poll = {
id: pollId,
question,
createdBy,
timestamp: Date.now(),
options: []
};
yPolls.push([poll]);
return pollId;
}
export function addOption(pollId: string, text: string, createdBy: string): void {
const polls = yPolls.toArray();
const pollIndex = polls.findIndex(p => p.id === pollId);
if (pollIndex !== -1) {
const poll = polls[pollIndex];
const option: PollOption = {
id: Math.random().toString(36).substr(2, 9),
text,
votes: 0,
votedBy: [],
createdBy,
timestamp: Date.now()
};
const updatedPoll = {
...poll,
options: [...poll.options, option]
};
yPolls.delete(pollIndex, 1);
yPolls.insert(pollIndex, [updatedPoll]);
}
}
export function voteForOption(pollId: string, optionId: string, userId: string): void {
const polls = yPolls.toArray();
const pollIndex = polls.findIndex(p => p.id === pollId);
if (pollIndex !== -1) {
const poll = polls[pollIndex];
const optionIndex = poll.options.findIndex(opt => opt.id === optionId);
if (optionIndex !== -1) {
const option = poll.options[optionIndex];
if (option.votedBy.includes(userId)) {
return;
}
const updatedOption = {
...option,
votes: option.votes + 1,
votedBy: [...option.votedBy, userId]
};
const updatedOptions = [...poll.options];
updatedOptions[optionIndex] = updatedOption;
const updatedPoll = {
...poll,
options: updatedOptions
};
yPolls.delete(pollIndex, 1);
yPolls.insert(pollIndex, [updatedPoll]);
}
}
}
export function getPolls(): Poll[] {
return yPolls.toArray();
}
export function getPoll(pollId: string): Poll | undefined {
return yPolls.toArray().find(p => p.id === pollId);
}

View File

@@ -1,9 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -1,17 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

View File

@@ -1,22 +0,0 @@
export interface PollOption {
id: string;
text: string;
votes: number;
votedBy: string[];
createdBy: string;
timestamp: number;
}
export interface Poll {
id: string;
question: string;
createdBy: string;
timestamp: number;
options: PollOption[];
}
export interface ConnectionStatus {
websocket: boolean;
webrtc: boolean;
peers: number;
}

View File

@@ -1,11 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,10 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true
}
})

7
ionic.config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "IonicAngularVersion",
"integrations": {
"capacitor": {}
},
"type": "angular"
}

44
karma.conf.js Normal file
View File

@@ -0,0 +1,44 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/app'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

19093
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@@ -0,0 +1,68 @@
{
"name": "IonicAngularVersion",
"version": "0.0.1",
"author": "Ionic Framework",
"homepage": "https://ionicframework.com/",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lint": "ng lint"
},
"private": true,
"dependencies": {
"@angular/animations": "^20.0.0",
"@angular/common": "^20.0.0",
"@angular/compiler": "^20.0.0",
"@angular/core": "^20.0.0",
"@angular/forms": "^20.0.0",
"@angular/platform-browser": "^20.0.0",
"@angular/platform-browser-dynamic": "^20.0.0",
"@angular/router": "^20.0.0",
"@capacitor/app": "8.0.1",
"@capacitor/core": "8.2.0",
"@capacitor/haptics": "8.0.1",
"@capacitor/keyboard": "8.0.1",
"@capacitor/status-bar": "8.0.1",
"@ionic/angular": "^8.0.0",
"dexie": "^4.3.0",
"ionicons": "^7.0.0",
"peerjs": "^1.5.5",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^20.0.0",
"@angular-eslint/builder": "^20.0.0",
"@angular-eslint/eslint-plugin": "^20.0.0",
"@angular-eslint/eslint-plugin-template": "^20.0.0",
"@angular-eslint/schematics": "^20.0.0",
"@angular-eslint/template-parser": "^20.0.0",
"@angular/cli": "^20.0.0",
"@angular/compiler-cli": "^20.0.0",
"@angular/language-service": "^20.0.0",
"@capacitor/cli": "8.2.0",
"@ionic/angular-toolkit": "^12.0.0",
"@types/jasmine": "~5.1.0",
"@types/qrcode": "^1.5.6",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"eslint": "^9.16.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsdoc": "^48.2.1",
"eslint-plugin-prefer-arrow": "1.2.2",
"jasmine-core": "~5.1.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.0"
},
"description": "An Ionic project"
}

View File

@@ -1,4 +0,0 @@
PORT=3000
YJS_WS_PORT=1234
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173

5
server/.gitignore vendored
View File

@@ -1,5 +0,0 @@
node_modules/
dist/
.env
*.log
.DS_Store

2102
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
{
"name": "p2p-poll-server",
"version": "1.0.0",
"description": "Backend server for P2P polling app with Yjs and WebRTC signaling",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"keywords": ["yjs", "websocket", "webrtc", "p2p"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.14.2",
"y-websocket": "^1.5.0",
"yjs": "^13.6.8",
"cors": "^2.8.5",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/express": "^4.17.20",
"@types/ws": "^8.5.8",
"@types/cors": "^2.8.15",
"@types/node": "^20.9.0",
"tsx": "^4.6.2",
"typescript": "^5.2.2"
}
}

View File

@@ -1,59 +0,0 @@
import express from 'express';
import http from 'http';
import cors from 'cors';
import dotenv from 'dotenv';
import { createYjsServer } from './yjs-server';
import { createSignalingServer } from './signaling-server';
import { logger } from './utils/logger';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true
}));
app.use(express.json());
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
services: {
yjs: 'running',
signaling: 'running'
}
});
});
app.get('/', (req, res) => {
res.json({
message: 'P2P Poll Server',
endpoints: {
health: '/health',
yjs: 'ws://localhost:' + PORT + '/yjs',
signaling: 'ws://localhost:' + PORT + '/signal'
}
});
});
const server = http.createServer(app);
createYjsServer(server, PORT as number);
createSignalingServer(server);
server.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Yjs WebSocket: ws://localhost:${PORT}/yjs`);
logger.info(`Signaling WebSocket: ws://localhost:${PORT}/signal`);
});
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received: closing HTTP server');
server.close(() => {
logger.info('HTTP server closed');
});
});

View File

@@ -1,126 +0,0 @@
import { WebSocketServer, WebSocket } from 'ws';
import http from 'http';
import { SignalingMessage } from './types/poll.types';
import { logger } from './utils/logger';
interface Client {
id: string;
ws: WebSocket;
roomId: string;
}
export function createSignalingServer(server: http.Server) {
const wss = new WebSocketServer({
server,
path: '/signal'
});
const clients = new Map<string, Client>();
const rooms = new Map<string, Set<string>>();
wss.on('connection', (ws: WebSocket) => {
let clientId: string | null = null;
ws.on('message', (data: Buffer) => {
try {
const message: SignalingMessage = JSON.parse(data.toString());
switch (message.type) {
case 'join':
clientId = message.from;
const roomId = message.roomId || 'default-room';
clients.set(clientId, { id: clientId, ws, roomId });
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId)!.add(clientId);
logger.info(`Client ${clientId} joined room ${roomId}`);
const roomClients = Array.from(rooms.get(roomId)!).filter(id => id !== clientId);
ws.send(JSON.stringify({
type: 'peers',
peers: roomClients
}));
roomClients.forEach(peerId => {
const peer = clients.get(peerId);
if (peer && peer.ws.readyState === WebSocket.OPEN) {
peer.ws.send(JSON.stringify({
type: 'peer-joined',
peerId: clientId
}));
}
});
break;
case 'offer':
case 'answer':
case 'ice-candidate':
if (message.to) {
const targetClient = clients.get(message.to);
if (targetClient && targetClient.ws.readyState === WebSocket.OPEN) {
targetClient.ws.send(JSON.stringify({
type: message.type,
from: message.from,
data: message.data
}));
}
}
break;
case 'leave':
handleClientLeave(message.from);
break;
}
} catch (error) {
logger.error('Error processing signaling message:', error);
}
});
ws.on('close', () => {
if (clientId) {
handleClientLeave(clientId);
}
});
ws.on('error', (error) => {
logger.error('WebSocket error:', error);
});
});
function handleClientLeave(clientId: string) {
const client = clients.get(clientId);
if (client) {
const roomId = client.roomId;
const room = rooms.get(roomId);
if (room) {
room.delete(clientId);
room.forEach(peerId => {
const peer = clients.get(peerId);
if (peer && peer.ws.readyState === WebSocket.OPEN) {
peer.ws.send(JSON.stringify({
type: 'peer-left',
peerId: clientId
}));
}
});
if (room.size === 0) {
rooms.delete(roomId);
}
}
clients.delete(clientId);
logger.info(`Client ${clientId} left room ${roomId}`);
}
}
logger.info('Signaling server running at path /signal');
return wss;
}

View File

@@ -1,24 +0,0 @@
export interface PollOption {
id: string;
text: string;
votes: number;
votedBy: string[];
createdBy: string;
timestamp: number;
}
export interface Poll {
id: string;
question: string;
createdBy: string;
timestamp: number;
options: PollOption[];
}
export interface SignalingMessage {
type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave';
from: string;
to?: string;
data?: any;
roomId?: string;
}

View File

@@ -1,16 +0,0 @@
export const logger = {
info: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
warn: (message: string, ...args: any[]) => {
console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, ...args);
},
debug: (message: string, ...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
console.debug(`[DEBUG] ${new Date().toISOString()} - ${message}`, ...args);
}
}
};

View File

@@ -1,26 +0,0 @@
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';
import http from 'http';
import { logger } from './utils/logger';
export function createYjsServer(server: http.Server, port: number) {
const wss = new WebSocketServer({
server,
path: '/yjs'
});
wss.on('connection', (ws, req) => {
const docName = req.url?.split('?')[1]?.split('=')[1] || 'default-poll';
logger.info(`New Yjs connection for document: ${docName}`);
setupWSConnection(ws, req, { docName });
});
wss.on('error', (error) => {
logger.error('Yjs WebSocket server error:', error);
});
logger.info(`Yjs WebSocket server running on port ${port} at path /yjs`);
return wss;
}

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,63 @@
import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
const routes: Routes = [
// Default redirect
{
path: '',
redirectTo: 'home',
pathMatch: 'full',
},
// Home: list of all surveys created by this user
{
path: 'home',
loadChildren: () => import('./home/home.module').then((m) => m.HomePageModule),
},
// Create a new survey
{
path: 'create-survey',
loadChildren: () =>
import('./pages/create-survey/create-survey.module').then(
(m) => m.CreateSurveyPageModule
),
},
// Edit an existing survey (id param)
{
path: 'create-survey/:id',
loadChildren: () =>
import('./pages/create-survey/create-survey.module').then(
(m) => m.CreateSurveyPageModule
),
},
// Survey detail: manage settings, generate links, start hosting
{
path: 'survey/:id',
loadChildren: () =>
import('./pages/survey-detail/survey-detail.module').then(
(m) => m.SurveyDetailPageModule
),
},
// Survey results: live aggregated view
{
path: 'survey/:id/results',
loadChildren: () =>
import('./pages/survey-results/survey-results.module').then(
(m) => m.SurveyResultsPageModule
),
},
// Participate: opened by participants via their unique link
// Uses query params: ?host=survey-{id}&token={uuid}
{
path: 'participate',
loadChildren: () =>
import('./pages/participate/participate.module').then(
(m) => m.ParticipatePageModule
),
},
];
@NgModule({
imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,3 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>

View File

View File

@@ -0,0 +1,21 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});

11
src/app/app.component.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
standalone: false,
})
export class AppComponent {
constructor() {}
}

16
src/app/app.module.ts Normal file
View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule],
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,46 @@
/**
* database.ts
* Dexie (IndexedDB) singleton for the P2P Survey App.
*
* All survey data — surveys, participant tokens, and responses — lives
* exclusively in the creator's browser. Dexie wraps the raw IndexedDB API
* with a clean, Promise-based interface and live-query support.
*
* Usage (inject via InjectionToken, see main.ts):
* constructor(@Inject(DATABASE_TOKEN) private db: AppDatabase) {}
*/
import Dexie, { type Table } from 'dexie';
import type { Survey, Participant, Response } from '../shared/models/survey.models';
/** Typed Dexie database class */
export class AppDatabase extends Dexie {
/** All surveys created by this user */
surveys!: Table<Survey, string>;
/**
* Pre-generated participant tokens.
* Primary key: token (UUID string).
*/
participants!: Table<Participant, string>;
/**
* Submitted responses.
* Primary key: id (UUID string).
*/
responses!: Table<Response, string>;
constructor() {
super('P2PSurveyDB');
this.version(1).stores({
// Indexed fields: primary key first, then fields used in queries
surveys: 'id, status, createdAt',
participants: 'token, surveyId, locked',
responses: 'id, surveyId, participantToken, submittedAt',
});
}
}
/** Module-level singleton — import this wherever you need direct DB access */
export const db = new AppDatabase();

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomePage } from './home.page';
const routes: Routes = [
{
path: '',
component: HomePage,
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HomePageRoutingModule {}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { HomePageRoutingModule } from './home-routing.module';
import { HomePage } from './home.page';
@NgModule({
imports: [CommonModule, FormsModule, IonicModule, HomePageRoutingModule],
declarations: [HomePage],
})
export class HomePageModule {}

View File

@@ -0,0 +1,54 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>My Surveys</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<!-- Empty state -->
<div *ngIf="surveys.length === 0" class="empty-state">
<ion-icon name="clipboard-outline" size="large"></ion-icon>
<h2>No Surveys Yet</h2>
<p>Create your first survey to get started.</p>
<ion-button (click)="createNewSurvey()" expand="block" class="ion-margin-top">
<ion-icon slot="start" name="add-outline"></ion-icon>
Create Survey
</ion-button>
</div>
<!-- Survey list -->
<ion-list *ngIf="surveys.length > 0" lines="none" class="survey-list">
<ion-item-sliding *ngFor="let survey of surveys">
<ion-item button (click)="openSurvey(survey)" detail="true">
<ion-label>
<h2>{{ survey.title }}</h2>
<p>
{{ survey.questions.length }} question{{ survey.questions.length !== 1 ? 's' : '' }}
&bull;
{{ responseCounts[survey.id] ?? 0 }} response{{ (responseCounts[survey.id] ?? 0) !== 1 ? 's' : '' }}
</p>
<p>
<ion-badge [color]="statusColor(survey.status)">{{ survey.status }}</ion-badge>
</p>
</ion-label>
</ion-item>
<!-- Swipe-to-reveal action buttons -->
<ion-item-options side="end">
<ion-item-option color="primary" (click)="editSurvey(survey, $event)">
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
</ion-item-option>
<ion-item-option color="danger" (click)="confirmDelete(survey, $event)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<!-- Floating action button -->
<ion-fab vertical="bottom" horizontal="end" slot="fixed">
<ion-fab-button (click)="createNewSurvey()" color="primary">
<ion-icon name="add-outline"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>

View File

@@ -0,0 +1,34 @@
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
ion-icon {
font-size: 64px;
color: var(--ion-color-medium);
margin-bottom: 16px;
}
h2 {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 8px;
}
p {
color: var(--ion-color-medium);
margin-bottom: 24px;
}
}
.survey-list {
padding: 8px;
}
ion-item-sliding ion-item {
--border-radius: 8px;
margin-bottom: 8px;
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { HomePage } from './home.page';
describe('HomePage', () => {
let component: HomePage;
let fixture: ComponentFixture<HomePage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomePage],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(HomePage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

124
src/app/home/home.page.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* home.page.ts
* Home page — displays all surveys created by this user.
* Surveys are loaded from IndexedDB via a live query so the list
* updates automatically when surveys are added or deleted.
*/
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { AlertController, ToastController } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { SurveyService } from '../services/survey.service';
import { ResponseService } from '../services/response.service';
import type { Survey } from '../shared/models/survey.models';
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
standalone: false,
})
export class HomePage implements OnInit, OnDestroy {
surveys: Survey[] = [];
/** Map from surveyId → response count (refreshed on each emission) */
responseCounts: Record<string, number | undefined> = {};
private surveySubscription?: Subscription;
constructor(
private router: Router,
private surveyService: SurveyService,
private responseService: ResponseService,
private alertCtrl: AlertController,
private toastCtrl: ToastController
) {}
ngOnInit(): void {
// Subscribe to the live query — list updates automatically
this.surveySubscription = this.surveyService
.getAllSurveys$()
.subscribe(async (surveys) => {
this.surveys = surveys;
// Refresh response counts whenever the survey list changes
await this.loadResponseCounts(surveys);
});
}
ngOnDestroy(): void {
this.surveySubscription?.unsubscribe();
}
// -------------------------------------------------------------------------
// Navigation
// -------------------------------------------------------------------------
/** Navigate to the create-survey page */
createNewSurvey(): void {
this.router.navigate(['/create-survey']);
}
/** Navigate to the detail page for a survey */
openSurvey(survey: Survey): void {
this.router.navigate(['/survey', survey.id]);
}
/** Navigate to the edit page for a survey */
editSurvey(survey: Survey, event: Event): void {
event.stopPropagation(); // Prevent the card click from also firing
this.router.navigate(['/create-survey', survey.id]);
}
// -------------------------------------------------------------------------
// Deletion
// -------------------------------------------------------------------------
/** Ask the user to confirm before deleting a survey */
async confirmDelete(survey: Survey, event: Event): Promise<void> {
event.stopPropagation();
const alert = await this.alertCtrl.create({
header: 'Delete Survey',
message: `Delete "${survey.title}"? This will permanently remove all participant links and responses.`,
buttons: [
{ text: 'Cancel', role: 'cancel' },
{
text: 'Delete',
role: 'destructive',
handler: () => this.deleteSurvey(survey),
},
],
});
await alert.present();
}
private async deleteSurvey(survey: Survey): Promise<void> {
await this.surveyService.deleteSurvey(survey.id);
const toast = await this.toastCtrl.create({
message: `"${survey.title}" was deleted.`,
duration: 2000,
color: 'medium',
});
await toast.present();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private async loadResponseCounts(surveys: Survey[]): Promise<void> {
const counts: Record<string, number> = {};
await Promise.all(
surveys.map(async (s) => {
const responses = await this.responseService.getResponses(s.id);
counts[s.id] = responses.length;
})
);
this.responseCounts = counts;
}
/** Returns a human-readable status label with an Ionic color */
statusColor(status: Survey['status']): string {
return status === 'active' ? 'success' : status === 'closed' ? 'medium' : 'warning';
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { CreateSurveyPage } from './create-survey.page';
const routes: Routes = [
{
path: '',
component: CreateSurveyPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class CreateSurveyPageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { CreateSurveyPageRoutingModule } from './create-survey-routing.module';
import { CreateSurveyPage } from './create-survey.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
CreateSurveyPageRoutingModule
],
declarations: [CreateSurveyPage]
})
export class CreateSurveyPageModule {}

View File

@@ -0,0 +1,131 @@
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-button (click)="cancel()">
<ion-icon slot="icon-only" name="close-outline"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>{{ isEditMode ? 'Edit Survey' : 'New Survey' }}</ion-title>
<ion-buttons slot="end">
<ion-button [disabled]="!isFormValid" (click)="save()" strong>
{{ isEditMode ? 'Update' : 'Create' }}
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<!-- Survey metadata -->
<ion-card>
<ion-card-header>
<ion-card-title>Survey Details</ion-card-title>
</ion-card-header>
<ion-card-content>
<ion-item lines="none">
<ion-label position="stacked">Title *</ion-label>
<ion-input
[(ngModel)]="title"
placeholder="e.g. Employee Feedback Q2"
maxlength="120"
clearInput="true">
</ion-input>
</ion-item>
<ion-item lines="none">
<ion-label position="stacked">Description (optional)</ion-label>
<ion-textarea
[(ngModel)]="description"
placeholder="Briefly explain the purpose of this survey…"
rows="3"
maxlength="500">
</ion-textarea>
</ion-item>
</ion-card-content>
</ion-card>
<!-- Questions -->
<ion-card *ngFor="let question of questions; let i = index; trackBy: trackQuestion">
<ion-card-header>
<ion-card-subtitle>Question {{ i + 1 }}</ion-card-subtitle>
<div class="question-actions">
<ion-button fill="clear" size="small" (click)="moveQuestionUp(i)" [disabled]="i === 0">
<ion-icon slot="icon-only" name="chevron-up-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="moveQuestionDown(i)" [disabled]="i === questions.length - 1">
<ion-icon slot="icon-only" name="chevron-down-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" color="danger" (click)="removeQuestion(i)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-button>
</div>
</ion-card-header>
<ion-card-content>
<!-- Question text -->
<ion-item lines="none">
<ion-label position="stacked">Question Text *</ion-label>
<ion-input
[(ngModel)]="question.text"
placeholder="Enter your question…"
maxlength="300">
</ion-input>
</ion-item>
<!-- Question type -->
<ion-item lines="none">
<ion-label position="stacked">Type</ion-label>
<ion-select [(ngModel)]="question.type" (ionChange)="onTypeChange(question)">
<ion-select-option value="text">Free Text</ion-select-option>
<ion-select-option value="multiple_choice">Multiple Choice</ion-select-option>
<ion-select-option value="yes_no">Yes / No</ion-select-option>
<ion-select-option value="rating">Rating (15)</ion-select-option>
</ion-select>
</ion-item>
<!-- Multiple choice options -->
<div *ngIf="question.type === 'multiple_choice'" class="options-section">
<ion-list-header>
<ion-label>Answer Options</ion-label>
</ion-list-header>
<ion-item
*ngFor="let option of question.options; let oi = index; trackBy: trackOption"
lines="none">
<ion-input
[(ngModel)]="question.options![oi]"
[placeholder]="'Option ' + (oi + 1)"
maxlength="200">
</ion-input>
<ion-button
slot="end"
fill="clear"
color="danger"
size="small"
(click)="removeOption(question, oi)"
[disabled]="(question.options?.length ?? 0) <= 2">
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
</ion-button>
</ion-item>
<ion-button fill="clear" size="small" (click)="addOption(question)">
<ion-icon slot="start" name="add-outline"></ion-icon>
Add Option
</ion-button>
</div>
<!-- Required toggle -->
<ion-item lines="none">
<ion-label>Required</ion-label>
<ion-toggle [(ngModel)]="question.required" slot="end"></ion-toggle>
</ion-item>
</ion-card-content>
</ion-card>
<!-- Add question button -->
<ion-button expand="block" fill="outline" (click)="addQuestion()" class="ion-margin-top">
<ion-icon slot="start" name="add-circle-outline"></ion-icon>
Add Question
</ion-button>
<!-- Validation hint -->
<p *ngIf="questions.length === 0" class="hint-text">Add at least one question to continue.</p>
</ion-content>

View File

@@ -0,0 +1,18 @@
.options-section {
margin-top: 8px;
padding: 8px 0;
border-top: 1px solid var(--ion-color-light);
}
.question-actions {
display: flex;
justify-content: flex-end;
margin-top: -8px;
}
.hint-text {
color: var(--ion-color-medium);
font-size: 0.875rem;
text-align: center;
padding: 8px 16px;
}

View File

@@ -0,0 +1,203 @@
/**
* create-survey.page.ts
* Page for creating a new survey or editing an existing one.
*
* When accessed via /create-survey → creates a new survey
* When accessed via /create-survey/:id → loads and edits the existing survey
*/
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastController } from '@ionic/angular';
import { SurveyService } from '../../services/survey.service';
import type { Survey, Question } from '../../shared/models/survey.models';
@Component({
selector: 'app-create-survey',
templateUrl: './create-survey.page.html',
styleUrls: ['./create-survey.page.scss'],
standalone: false,
})
export class CreateSurveyPage implements OnInit {
/** True when editing an existing survey */
isEditMode = false;
existingSurveyId?: string;
// Form fields
title = '';
description = '';
questions: Question[] = [];
/** Track which question's options are being edited */
expandedQuestionIndex: number | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private surveyService: SurveyService,
private toastCtrl: ToastController
) {}
async ngOnInit(): Promise<void> {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.isEditMode = true;
this.existingSurveyId = id;
await this.loadSurvey(id);
} else {
// Start with one empty question for a better UX
this.addQuestion();
}
}
private async loadSurvey(id: string): Promise<void> {
const survey = await this.surveyService.getSurvey(id);
if (!survey) {
const toast = await this.toastCtrl.create({
message: 'Survey not found.',
duration: 2000,
color: 'danger',
});
await toast.present();
this.router.navigate(['/home']);
return;
}
this.title = survey.title;
this.description = survey.description ?? '';
// Deep copy so edits don't mutate the original until saved
this.questions = JSON.parse(JSON.stringify(survey.questions));
}
// -------------------------------------------------------------------------
// Question management
// -------------------------------------------------------------------------
addQuestion(): void {
const question: Question = {
id: crypto.randomUUID(),
text: '',
type: 'text',
required: false,
};
this.questions.push(question);
this.expandedQuestionIndex = this.questions.length - 1;
}
removeQuestion(index: number): void {
this.questions.splice(index, 1);
if (this.expandedQuestionIndex === index) {
this.expandedQuestionIndex = null;
}
}
moveQuestionUp(index: number): void {
if (index === 0) return;
const temp = this.questions[index - 1];
this.questions[index - 1] = this.questions[index];
this.questions[index] = temp;
}
moveQuestionDown(index: number): void {
if (index === this.questions.length - 1) return;
const temp = this.questions[index + 1];
this.questions[index + 1] = this.questions[index];
this.questions[index] = temp;
}
toggleExpand(index: number): void {
this.expandedQuestionIndex =
this.expandedQuestionIndex === index ? null : index;
}
onTypeChange(question: Question): void {
// Initialise options array when switching to multiple_choice
if (question.type === 'multiple_choice' && !question.options?.length) {
question.options = ['Option 1', 'Option 2'];
}
}
addOption(question: Question): void {
if (!question.options) question.options = [];
question.options.push(`Option ${question.options.length + 1}`);
}
removeOption(question: Question, optIndex: number): void {
question.options?.splice(optIndex, 1);
}
trackOption(index: number): number {
return index;
}
trackQuestion(index: number): number {
return index;
}
// -------------------------------------------------------------------------
// Validation
// -------------------------------------------------------------------------
get isFormValid(): boolean {
if (!this.title.trim()) return false;
if (this.questions.length === 0) return false;
return this.questions.every(
(q) =>
q.text.trim() &&
(q.type !== 'multiple_choice' || (q.options && q.options.length >= 2))
);
}
// -------------------------------------------------------------------------
// Save / Update
// -------------------------------------------------------------------------
async save(): Promise<void> {
if (!this.isFormValid) return;
const questionsToSave = this.questions.map((q) => ({
...q,
text: q.text.trim(),
// Strip empty options for multiple_choice
options:
q.type === 'multiple_choice'
? q.options?.filter((o) => o.trim()) ?? []
: undefined,
}));
if (this.isEditMode && this.existingSurveyId) {
await this.surveyService.updateSurvey(this.existingSurveyId, {
title: this.title.trim(),
description: this.description.trim() || undefined,
questions: questionsToSave,
});
const toast = await this.toastCtrl.create({
message: 'Survey updated.',
duration: 2000,
color: 'success',
});
await toast.present();
this.router.navigate(['/survey', this.existingSurveyId]);
} else {
const survey = await this.surveyService.createSurvey({
title: this.title.trim(),
description: this.description.trim() || undefined,
questions: questionsToSave,
});
const toast = await this.toastCtrl.create({
message: 'Survey created.',
duration: 2000,
color: 'success',
});
await toast.present();
this.router.navigate(['/survey', survey.id]);
}
}
cancel(): void {
if (this.isEditMode && this.existingSurveyId) {
this.router.navigate(['/survey', this.existingSurveyId]);
} else {
this.router.navigate(['/home']);
}
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ParticipatePage } from './participate.page';
const routes: Routes = [
{
path: '',
component: ParticipatePage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ParticipatePageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ParticipatePageRoutingModule } from './participate-routing.module';
import { ParticipatePage } from './participate.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ParticipatePageRoutingModule
],
declarations: [ParticipatePage]
})
export class ParticipatePageModule {}

View File

@@ -0,0 +1,185 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>Survey</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<!-- ── Connecting ── -->
<div *ngIf="state === 'connecting'" class="state-card">
<ion-spinner name="crescent"></ion-spinner>
<h3>Connecting to survey host…</h3>
<p>Please wait while we establish a peer-to-peer connection.</p>
</div>
<!-- ── Connected (waiting for survey data) ── -->
<div *ngIf="state === 'connected'" class="state-card">
<ion-spinner name="dots"></ion-spinner>
<h3>Loading survey…</h3>
</div>
<!-- ── Host offline ── -->
<ion-card *ngIf="state === 'host-offline'" color="warning">
<ion-card-header>
<ion-card-title>
<ion-icon name="wifi-outline"></ion-icon>
Host is Offline
</ion-card-title>
</ion-card-header>
<ion-card-content>
<p>
The survey host's browser is not currently accepting connections.
The survey creator needs to open the survey and click <strong>Start Hosting</strong>.
</p>
<p>Please try again later or ask the survey creator to come online.</p>
</ion-card-content>
</ion-card>
<!-- ── Error ── -->
<ion-card *ngIf="state === 'error'" color="danger">
<ion-card-header>
<ion-card-title>
<ion-icon name="alert-circle-outline"></ion-icon>
Error
</ion-card-title>
</ion-card-header>
<ion-card-content>
<p>{{ errorMessage }}</p>
</ion-card-content>
</ion-card>
<!-- ── Survey form ── -->
<ng-container *ngIf="state === 'survey-loaded' && survey">
<div class="survey-header">
<h2>{{ survey.title }}</h2>
<p *ngIf="survey.description" class="survey-description">{{ survey.description }}</p>
</div>
<!-- Question cards -->
<ion-card *ngFor="let question of survey.questions; let i = index">
<ion-card-header>
<ion-card-subtitle>
Question {{ i + 1 }}
<span *ngIf="question.required" class="required-mark">*</span>
</ion-card-subtitle>
<ion-card-title class="question-title">{{ question.text }}</ion-card-title>
</ion-card-header>
<ion-card-content>
<!-- Free text -->
<ion-item *ngIf="question.type === 'text'" lines="none">
<ion-textarea
[(ngModel)]="answers[question.id]"
placeholder="Your answer…"
rows="4"
maxlength="2000"
(ionBlur)="saveDraft()">
</ion-textarea>
</ion-item>
<!-- Multiple choice -->
<ion-radio-group
*ngIf="question.type === 'multiple_choice'"
[(ngModel)]="answers[question.id]"
(ngModelChange)="saveDraft()">
<ion-item *ngFor="let option of question.options" lines="none">
<ion-radio [value]="option" slot="start"></ion-radio>
<ion-label>{{ option }}</ion-label>
</ion-item>
</ion-radio-group>
<!-- Yes / No -->
<ion-radio-group
*ngIf="question.type === 'yes_no'"
[(ngModel)]="answers[question.id]"
(ngModelChange)="saveDraft()">
<ion-item lines="none">
<ion-radio value="Yes" slot="start"></ion-radio>
<ion-label>Yes</ion-label>
</ion-item>
<ion-item lines="none">
<ion-radio value="No" slot="start"></ion-radio>
<ion-label>No</ion-label>
</ion-item>
</ion-radio-group>
<!-- Rating 15 -->
<div *ngIf="question.type === 'rating'" class="rating-row">
<ion-button
*ngFor="let val of ratingValues()"
[fill]="answers[question.id] === val ? 'solid' : 'outline'"
[color]="answers[question.id] === val ? 'primary' : 'medium'"
(click)="answers[question.id] = val; saveDraft()"
size="small">
{{ val }}
</ion-button>
</div>
</ion-card-content>
</ion-card>
<p class="required-note">* Required question</p>
<div class="submit-area">
<ion-button expand="block" (click)="submit()" [disabled]="!isFormValid">
<ion-icon slot="start" name="checkmark-outline"></ion-icon>
Submit
</ion-button>
</div>
</ng-container>
<!-- ── Submitted ── -->
<div *ngIf="state === 'submitted'" class="state-card success-state">
<ion-icon name="checkmark-circle-outline" color="success" size="large"></ion-icon>
<h2>Thank you!</h2>
<p>Your response has been submitted successfully.</p>
<!-- Show results if host sent them -->
<ng-container *ngIf="results && survey">
<div class="results-divider">
<ion-label>Survey Results</ion-label>
</div>
<ion-card *ngFor="let q of survey.questions">
<ion-card-header>
<ion-card-title class="question-title">{{ q.text }}</ion-card-title>
</ion-card-header>
<ion-card-content>
<ng-container *ngIf="getQuestionResult(q.id) as qr">
<!-- Tally (multiple_choice / yes_no) -->
<div *ngIf="qr.tally">
<div *ngFor="let entry of tallyEntries(qr.tally)" class="bar-row">
<span class="bar-label">{{ entry.key }}</span>
<div class="bar-track">
<div class="bar-fill" [style.width.%]="tallyPercent(entry.count)"></div>
</div>
<span class="bar-count">{{ entry.count }} ({{ tallyPercent(entry.count) }}%)</span>
</div>
</div>
<!-- Text answers -->
<div *ngIf="qr.texts">
<p *ngFor="let t of qr.texts; let i = index">{{ i + 1 }}. {{ t }}</p>
</div>
<!-- Rating -->
<div *ngIf="qr.ratingDistribution">
<p>Average: <strong>{{ formatAvg(qr.ratingAvg) }}</strong> / 5</p>
<div *ngFor="let count of qr.ratingDistribution; let i = index" class="bar-row">
<span class="bar-label">{{ ratingLabel(i) }} ★</span>
<div class="bar-track">
<div class="bar-fill" [style.width.%]="tallyPercent(count)"></div>
</div>
<span class="bar-count">{{ count }}</span>
</div>
</div>
</ng-container>
</ion-card-content>
</ion-card>
</ng-container>
</div>
</ion-content>

View File

@@ -0,0 +1,119 @@
.state-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
text-align: center;
ion-spinner {
width: 48px;
height: 48px;
margin-bottom: 16px;
}
ion-icon {
font-size: 72px;
margin-bottom: 16px;
}
h3, h2 {
margin: 0 0 8px;
}
p {
color: var(--ion-color-medium);
max-width: 320px;
}
}
.success-state {
ion-icon {
color: var(--ion-color-success);
}
}
.survey-header {
margin-bottom: 16px;
h2 {
font-size: 1.4rem;
font-weight: 700;
margin: 0 0 4px;
}
}
.survey-description {
color: var(--ion-color-medium);
margin: 0;
}
.question-title {
font-size: 1rem;
font-weight: 600;
white-space: normal;
line-height: 1.4;
}
.required-mark {
color: var(--ion-color-danger);
margin-left: 4px;
}
.required-note {
color: var(--ion-color-medium);
font-size: 0.8rem;
padding: 0 16px;
}
.rating-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 8px 0;
}
.submit-area {
padding: 16px 0 32px;
}
.results-divider {
display: flex;
align-items: center;
margin: 24px 0 8px;
width: 100%;
font-weight: 600;
color: var(--ion-color-primary);
}
.bar-row {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.bar-label {
min-width: 60px;
font-size: 0.875rem;
}
.bar-track {
flex: 1;
height: 14px;
background: var(--ion-color-light);
border-radius: 7px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--ion-color-primary);
border-radius: 7px;
min-width: 2px;
}
.bar-count {
font-size: 0.8rem;
color: var(--ion-color-medium);
}

View File

@@ -0,0 +1,267 @@
/**
* participate.page.ts
* Survey participation page — opened by participants via their unique share link.
*
* URL format: /participate?host=survey-{surveyId}&token={participantToken}
*
* Connection flow:
* 1. Parse host peer ID and token from URL query params
* 2. Initialise a PeerJS peer with a random ID
* 3. Connect to the host peer
* 4. Send { type: 'join', token } to identify the participant
* 5. Receive survey data from host
* 6. Participant fills in answers (drafts saved via 'update' messages)
* 7. On submit, send { type: 'submit', ... } — host locks the token
* 8. Optionally receive aggregated results if the host has that setting enabled
*/
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ToastController } from '@ionic/angular';
import { DataConnection } from 'peerjs';
import { PeerService } from '../../services/peer.service';
import type {
Survey,
Question,
P2PMessage,
SurveyResults,
QuestionResult,
} from '../../shared/models/survey.models';
/** Possible states of the participant UI */
type ParticipantState =
| 'connecting' // Trying to reach the host peer
| 'connected' // DataConnection open, waiting for survey data
| 'survey-loaded' // Survey received, participant filling in answers
| 'submitted' // Final answers submitted and acknowledged
| 'host-offline' // Could not connect within the timeout
| 'error'; // Protocol error (invalid token, already submitted, etc.)
@Component({
selector: 'app-participate',
templateUrl: './participate.page.html',
styleUrls: ['./participate.page.scss'],
standalone: false,
})
export class ParticipatePage implements OnInit, OnDestroy {
state: ParticipantState = 'connecting';
errorMessage = '';
survey?: Survey;
/** Map from questionId → current answer value */
answers: Record<string, unknown> = {};
results?: SurveyResults;
/** The participant's unique token (from URL) */
private token = '';
/** The host peer ID (from URL) */
private hostPeerId = '';
private conn?: DataConnection;
private offlineTimeout?: ReturnType<typeof setTimeout>;
constructor(
private route: ActivatedRoute,
private peerService: PeerService,
private toastCtrl: ToastController
) {}
async ngOnInit(): Promise<void> {
this.hostPeerId = this.route.snapshot.queryParamMap.get('host') ?? '';
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
if (!this.hostPeerId || !this.token) {
this.state = 'error';
this.errorMessage = 'Invalid link. Please check the URL and try again.';
return;
}
await this.connectToHost();
}
ngOnDestroy(): void {
clearTimeout(this.offlineTimeout);
this.peerService.destroy();
}
// -------------------------------------------------------------------------
// P2P connection
// -------------------------------------------------------------------------
private async connectToHost(): Promise<void> {
try {
// Initialise with a random peer ID (participants don't need a fixed ID)
await this.peerService.init();
this.conn = this.peerService.connectTo(this.hostPeerId);
// If the host does not respond within 8 seconds, show the offline card
this.offlineTimeout = setTimeout(() => {
if (this.state === 'connecting') {
this.state = 'host-offline';
}
}, 8000);
this.conn.on('open', () => {
clearTimeout(this.offlineTimeout);
this.state = 'connected';
// Identify ourselves to the host
this.conn!.send({ type: 'join', token: this.token } as P2PMessage);
});
this.conn.on('data', (rawMsg) => {
const msg = rawMsg as P2PMessage;
this.handleHostMessage(msg);
});
this.conn.on('error', () => {
clearTimeout(this.offlineTimeout);
this.state = 'host-offline';
});
this.conn.on('close', () => {
// Do not override 'submitted' state on normal close
if (this.state !== 'submitted') {
this.state = 'host-offline';
}
});
} catch {
this.state = 'host-offline';
}
}
/** Process a message received from the host */
private handleHostMessage(msg: P2PMessage): void {
switch (msg.type) {
case 'survey':
this.survey = msg.data;
// Pre-fill answers map with empty values
this.answers = {};
for (const q of this.survey.questions) {
this.answers[q.id] = q.type === 'rating' ? null : '';
}
this.state = 'survey-loaded';
break;
case 'ack':
// 'submitted' is handled as a state change
if (msg.status === 'submitted') {
this.state = 'submitted';
}
break;
case 'results':
this.results = msg.data;
break;
case 'error':
this.state = 'error';
this.errorMessage = this.friendlyError(msg.reason);
break;
}
}
// -------------------------------------------------------------------------
// Form interactions
// -------------------------------------------------------------------------
/** Save a draft without locking the token */
saveDraft(): void {
if (!this.conn?.open) return;
this.conn.send({
type: 'update',
token: this.token,
answers: this.answers,
} as P2PMessage);
}
/** Submit final answers — the token will be locked on the host side */
async submit(): Promise<void> {
if (!this.isFormValid) {
const toast = await this.toastCtrl.create({
message: 'Please answer all required questions before submitting.',
duration: 3000,
color: 'warning',
});
await toast.present();
return;
}
if (!this.conn?.open) {
this.state = 'host-offline';
return;
}
this.conn.send({
type: 'submit',
token: this.token,
answers: this.answers,
} as P2PMessage);
}
// -------------------------------------------------------------------------
// Validation
// -------------------------------------------------------------------------
get isFormValid(): boolean {
if (!this.survey) return false;
return this.survey.questions
.filter((q) => q.required)
.every((q) => {
const ans = this.answers[q.id];
return ans !== null && ans !== undefined && ans !== '';
});
}
// -------------------------------------------------------------------------
// Results display helpers
// -------------------------------------------------------------------------
getQuestionResult(questionId: string): QuestionResult | undefined {
return this.results?.answers[questionId];
}
tallyEntries(tally: Record<string, number>): { key: string; count: number }[] {
return Object.entries(tally)
.map(([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count);
}
tallyPercent(count: number): number {
if (!this.results) return 0;
return Math.round((count / this.results.totalResponses) * 100);
}
formatAvg(avg: number | undefined): string {
return avg != null ? avg.toFixed(1) : '';
}
ratingLabel(index: number): string {
return String(index + 1);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/** Returns an array [1, 2, 3, 4, 5] for the rating question template */
ratingValues(): number[] {
return [1, 2, 3, 4, 5];
}
private friendlyError(
reason: 'invalid_token' | 'already_submitted' | 'survey_not_found' | 'survey_closed' | 'survey_draft'
): string {
switch (reason) {
case 'invalid_token':
return 'This link is not valid for this survey. Please check the URL.';
case 'already_submitted':
return 'You have already submitted a response for this survey. Each link can only be used once.';
case 'survey_not_found':
return 'The survey could not be found on the host. It may have been deleted.';
case 'survey_draft':
return 'This survey is not yet open for responses. Please try again later.';
case 'survey_closed':
return 'This survey is closed and no longer accepting responses.';
}
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SurveyDetailPage } from './survey-detail.page';
const routes: Routes = [
{
path: '',
component: SurveyDetailPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class SurveyDetailPageRoutingModule {}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { SurveyDetailPageRoutingModule } from './survey-detail-routing.module';
import { SurveyDetailPage } from './survey-detail.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
SurveyDetailPageRoutingModule
],
declarations: [SurveyDetailPage]
})
export class SurveyDetailPageModule {}

View File

@@ -0,0 +1,167 @@
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-back-button defaultHref="/home"></ion-back-button>
</ion-buttons>
<ion-title>{{ survey?.title ?? 'Survey' }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="editSurvey()">
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding" *ngIf="survey">
<!-- Overview card -->
<ion-card>
<ion-card-header>
<ion-card-title>{{ survey.title }}</ion-card-title>
<ion-card-subtitle *ngIf="survey.description">{{ survey.description }}</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<p>
<strong>{{ survey.questions.length }}</strong> question{{ survey.questions.length !== 1 ? 's' : '' }}
&bull;
<strong>{{ responseCount }}</strong> response{{ responseCount !== 1 ? 's' : '' }}
&bull;
<ion-badge [color]="survey.status === 'active' ? 'success' : 'medium'">{{ survey.status }}</ion-badge>
</p>
<p class="hint-text">Created: {{ survey.createdAt | date:'medium' }}</p>
</ion-card-content>
</ion-card>
<!-- Settings -->
<ion-card>
<ion-card-header>
<ion-card-title>Settings</ion-card-title>
</ion-card-header>
<ion-card-content>
<ion-item lines="none">
<ion-label>
<h3>Show results to participants</h3>
<p>After submitting, participants see aggregated results.</p>
</ion-label>
<ion-toggle
[(ngModel)]="survey.showResultsToParticipants"
(ionChange)="toggleShowResults()"
slot="end">
</ion-toggle>
</ion-item>
<ion-item lines="none">
<ion-label>Survey Status</ion-label>
<ion-select
[(ngModel)]="survey.status"
(ionChange)="changeSurveyStatus(survey.status)"
slot="end">
<ion-select-option value="draft">Draft</ion-select-option>
<ion-select-option value="active">Active</ion-select-option>
<ion-select-option value="closed">Closed</ion-select-option>
</ion-select>
</ion-item>
</ion-card-content>
</ion-card>
<!-- Hosting control -->
<ion-card>
<ion-card-header>
<ion-card-title>Hosting</ion-card-title>
</ion-card-header>
<ion-card-content>
<ion-item lines="none" *ngIf="isHosting">
<ion-icon name="wifi" slot="start" color="success"></ion-icon>
<ion-label color="success">
<strong>Hosting active</strong>
<p>Participants can connect now. Keep this page open.</p>
</ion-label>
</ion-item>
<ion-item lines="none" *ngIf="!isHosting">
<ion-icon name="wifi-outline" slot="start" color="medium"></ion-icon>
<ion-label color="medium">
Not hosting. Start hosting to receive responses.
</ion-label>
</ion-item>
<div class="ion-margin-top button-row">
<ion-button *ngIf="!isHosting" expand="block" (click)="startHosting()"
[disabled]="survey.status !== 'active'">
<ion-icon slot="start" name="play-outline"></ion-icon>
Start Hosting
</ion-button>
<ion-button *ngIf="isHosting" expand="block" color="medium" fill="outline" (click)="stopHosting()">
<ion-icon slot="start" name="stop-outline"></ion-icon>
Stop Hosting
</ion-button>
<ion-button expand="block" fill="outline" (click)="viewResults()">
<ion-icon slot="start" name="bar-chart-outline"></ion-icon>
View Results
</ion-button>
</div>
</ion-card-content>
</ion-card>
<!-- Generate participant links -->
<ion-card>
<ion-card-header>
<ion-card-title>Share Links</ion-card-title>
</ion-card-header>
<ion-card-content>
<p>Generate unique links to share with participants. Each link can only be used once.</p>
<ion-item lines="none">
<ion-label>Number of links</ion-label>
<ion-input
type="number"
[(ngModel)]="linkCount"
min="1"
max="200"
slot="end"
style="max-width: 80px; text-align: right;">
</ion-input>
</ion-item>
<ion-button expand="block" (click)="generateLinks()" class="ion-margin-top">
<ion-icon slot="start" name="link-outline"></ion-icon>
Generate Links
</ion-button>
</ion-card-content>
</ion-card>
<!-- Participant list -->
<ion-card *ngIf="participants.length > 0">
<ion-card-header>
<ion-card-title>Participants ({{ participants.length }})</ion-card-title>
</ion-card-header>
<ion-card-content>
<ion-list lines="none">
<ion-item *ngFor="let p of participants">
<ion-label>
<h3>{{ p.label ?? ('Token ' + truncateToken(p.token)) }}</h3>
<p>
<ion-badge [color]="participantStatusColor(p)">{{ participantStatus(p) }}</ion-badge>
<span *ngIf="p.usedAt" class="hint-text"> &bull; Accessed {{ p.usedAt | date:'short' }}</span>
</p>
</ion-label>
<ion-button
slot="end"
fill="clear"
size="small"
(click)="copyLink(p.token)"
title="Copy link">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
</ion-list>
</ion-card-content>
</ion-card>
<!-- No participants yet -->
<ion-card *ngIf="participants.length === 0">
<ion-card-content>
<p class="hint-text ion-text-center">
No links generated yet. Use the form above to create share links.
</p>
</ion-card-content>
</ion-card>
</ion-content>

View File

@@ -0,0 +1,14 @@
.hint-text {
color: var(--ion-color-medium);
font-size: 0.85rem;
}
.button-row {
display: flex;
flex-direction: column;
gap: 8px;
}
ion-badge {
font-size: 0.75rem;
}

View File

@@ -0,0 +1,323 @@
/**
* survey-detail.page.ts
* Survey management page for the survey creator (host).
*
* Features:
* - View survey info and settings
* - Toggle whether participants can see aggregated results
* - Generate unique participant share links
* - Display the list of participants with their submission status
* - Start/stop hosting (activates the PeerJS peer so participants can connect)
*/
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastController } from '@ionic/angular';
import { DataConnection } from 'peerjs';
import { Subscription } from 'rxjs';
import { SurveyService } from '../../services/survey.service';
import { ResponseService } from '../../services/response.service';
import { PeerService } from '../../services/peer.service';
import type { Survey, Participant, P2PMessage } from '../../shared/models/survey.models';
@Component({
selector: 'app-survey-detail',
templateUrl: './survey-detail.page.html',
styleUrls: ['./survey-detail.page.scss'],
standalone: false,
})
export class SurveyDetailPage implements OnInit, OnDestroy {
survey?: Survey;
participants: Participant[] = [];
responseCount = 0;
/** Number of tokens to generate (bound to the input field) */
linkCount = 5;
/** True while the PeerJS peer is open and accepting connections */
isHosting = false;
/** Base URL used when constructing share links */
get appBaseUrl(): string {
return window.location.origin;
}
private surveyId = '';
private connectionSubscription?: Subscription;
private participantSubscription?: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router,
private surveyService: SurveyService,
private responseService: ResponseService,
private peerService: PeerService,
private toastCtrl: ToastController
) {}
async ngOnInit(): Promise<void> {
this.surveyId = this.route.snapshot.paramMap.get('id') ?? '';
if (!this.surveyId) {
this.router.navigate(['/home']);
return;
}
await this.loadSurvey();
this.subscribeToParticipants();
await this.loadResponseCount();
}
ngOnDestroy(): void {
this.connectionSubscription?.unsubscribe();
this.participantSubscription?.unsubscribe();
// Stop hosting when navigating away
this.stopHosting();
}
// -------------------------------------------------------------------------
// Data loading
// -------------------------------------------------------------------------
private async loadSurvey(): Promise<void> {
this.survey = await this.surveyService.getSurvey(this.surveyId);
if (!this.survey) {
const toast = await this.toastCtrl.create({
message: 'Survey not found.',
duration: 2000,
color: 'danger',
});
await toast.present();
this.router.navigate(['/home']);
}
}
private subscribeToParticipants(): void {
this.participantSubscription = this.surveyService
.getParticipants$(this.surveyId)
.subscribe((participants) => {
this.participants = participants;
});
}
private async loadResponseCount(): Promise<void> {
const responses = await this.responseService.getResponses(this.surveyId);
this.responseCount = responses.length;
}
// -------------------------------------------------------------------------
// Settings
// -------------------------------------------------------------------------
/** Persist the showResultsToParticipants flag after ngModel has updated it */
async toggleShowResults(): Promise<void> {
if (!this.survey) return;
await this.surveyService.updateSurvey(this.surveyId, {
showResultsToParticipants: this.survey.showResultsToParticipants,
});
}
async changeSurveyStatus(status: Survey['status']): Promise<void> {
if (!this.survey) return;
await this.surveyService.updateSurvey(this.surveyId, { status });
}
// -------------------------------------------------------------------------
// Link generation
// -------------------------------------------------------------------------
async generateLinks(): Promise<void> {
if (this.linkCount < 1 || this.linkCount > 200) return;
await this.surveyService.generateParticipantTokens(this.surveyId, this.linkCount);
const toast = await this.toastCtrl.create({
message: `${this.linkCount} link${this.linkCount !== 1 ? 's' : ''} generated.`,
duration: 2000,
color: 'success',
});
await toast.present();
}
/** Build the full share URL for a participant token */
buildLink(token: string): string {
return `${this.appBaseUrl}/participate?host=survey-${this.surveyId}&token=${token}`;
}
/** Copy a link to the clipboard and show a toast */
async copyLink(token: string): Promise<void> {
const link = this.buildLink(token);
await navigator.clipboard.writeText(link);
const toast = await this.toastCtrl.create({
message: 'Link copied to clipboard.',
duration: 1500,
color: 'medium',
});
await toast.present();
}
/** Truncate a UUID token for display (first 8 chars) */
truncateToken(token: string): string {
return token.substring(0, 8) + '…';
}
// -------------------------------------------------------------------------
// Hosting (PeerJS)
// -------------------------------------------------------------------------
async startHosting(): Promise<void> {
if (!this.survey) return;
try {
const peerId = `survey-${this.surveyId}`;
await this.peerService.init(peerId);
this.isHosting = true;
// Update survey status to active
await this.changeSurveyStatus('active');
// Listen for incoming participant connections
this.connectionSubscription = this.peerService.onConnection$.subscribe(
(conn) => this.handleParticipantConnection(conn)
);
const toast = await this.toastCtrl.create({
message: 'Hosting started. Participants can now connect.',
duration: 3000,
color: 'success',
});
await toast.present();
} catch (err) {
console.error('Failed to start hosting:', err);
const toast = await this.toastCtrl.create({
message: 'Could not start hosting. Check your network connection.',
duration: 3000,
color: 'danger',
});
await toast.present();
}
}
stopHosting(): void {
if (this.isHosting) {
this.connectionSubscription?.unsubscribe();
this.peerService.destroy();
this.isHosting = false;
}
}
/**
* Handle a new DataConnection from a participant.
* The participant will send a 'join' message with their token.
*/
private handleParticipantConnection(conn: DataConnection): void {
conn.on('open', () => {
// Connection is open; wait for the participant to identify themselves
});
conn.on('data', async (rawMsg) => {
const msg = rawMsg as P2PMessage;
await this.processMessage(conn, msg);
});
conn.on('error', (err) => {
console.error('Participant connection error:', err);
});
}
/** Process a single P2P message from a participant */
private async processMessage(conn: DataConnection, msg: P2PMessage): Promise<void> {
if (!this.survey) return;
if (msg.type === 'join') {
if (this.survey.status === 'draft') {
conn.send({ type: 'error', reason: 'survey_draft' } as P2PMessage);
return;
}
if (this.survey.status === 'closed') {
conn.send({ type: 'error', reason: 'survey_closed' } as P2PMessage);
return;
}
const participant = await this.surveyService.getParticipant(msg.token);
if (!participant || participant.surveyId !== this.surveyId) {
conn.send({ type: 'error', reason: 'invalid_token' } as P2PMessage);
return;
}
if (participant.locked) {
conn.send({ type: 'error', reason: 'already_submitted' } as P2PMessage);
return;
}
// Record first connection time
if (!participant.usedAt) {
await this.surveyService.markParticipantUsed(msg.token);
}
// Send survey questions to the participant
conn.send({
type: 'survey',
data: this.survey,
showResults: this.survey.showResultsToParticipants,
} as P2PMessage);
}
if (msg.type === 'update') {
const participant = await this.surveyService.getParticipant(msg.token);
if (!participant || participant.locked) return;
// Save draft (does not lock the token)
await this.responseService.saveResponse(this.surveyId, msg.token, msg.answers);
conn.send({ type: 'ack', status: 'updated' } as P2PMessage);
await this.loadResponseCount();
}
if (msg.type === 'submit') {
const participant = await this.surveyService.getParticipant(msg.token);
if (!participant || participant.locked) return;
// Save final response and lock the token
await this.responseService.saveResponse(this.surveyId, msg.token, msg.answers);
await this.surveyService.lockParticipantToken(msg.token);
conn.send({ type: 'ack', status: 'submitted' } as P2PMessage);
await this.loadResponseCount();
// If configured, push aggregated results back to the participant
if (this.survey.showResultsToParticipants) {
const results = await this.responseService.computeResults(this.survey);
conn.send({ type: 'results', data: results } as P2PMessage);
}
}
}
// -------------------------------------------------------------------------
// Navigation
// -------------------------------------------------------------------------
editSurvey(): void {
this.router.navigate(['/create-survey', this.surveyId]);
}
viewResults(): void {
this.router.navigate(['/survey', this.surveyId, 'results']);
}
goBack(): void {
this.router.navigate(['/home']);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
participantStatus(p: Participant): string {
if (p.locked) return 'submitted';
if (p.usedAt) return 'in progress';
return 'pending';
}
participantStatusColor(p: Participant): string {
if (p.locked) return 'success';
if (p.usedAt) return 'warning';
return 'medium';
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SurveyResultsPage } from './survey-results.page';
const routes: Routes = [
{
path: '',
component: SurveyResultsPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class SurveyResultsPageRoutingModule {}

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { SurveyResultsPageRoutingModule } from './survey-results-routing.module';
import { SurveyResultsPage } from './survey-results.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
SurveyResultsPageRoutingModule,
],
declarations: [SurveyResultsPage],
})
export class SurveyResultsPageModule {}

View File

@@ -0,0 +1,125 @@
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/survey/' + surveyId"></ion-back-button>
</ion-buttons>
<ion-title>Results</ion-title>
<ion-buttons slot="end">
<ion-button (click)="exportCsv()" [disabled]="responses.length === 0" title="Export CSV">
<ion-icon slot="icon-only" name="download-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<!-- Survey title and response count -->
<div class="results-header ion-padding">
<h2>{{ survey?.title }}</h2>
<p>
<strong>{{ responses.length }}</strong> response{{ responses.length !== 1 ? 's' : '' }} collected
</p>
</div>
<!-- Tab selector -->
<ion-segment [(ngModel)]="activeTab" class="ion-padding-horizontal">
<ion-segment-button value="summary">
<ion-label>Summary</ion-label>
</ion-segment-button>
<ion-segment-button value="individual">
<ion-label>Individual</ion-label>
</ion-segment-button>
</ion-segment>
<!-- No responses yet -->
<div *ngIf="responses.length === 0" class="empty-state ion-padding">
<ion-icon name="hourglass-outline" size="large"></ion-icon>
<h3>No responses yet</h3>
<p>Start hosting on the survey detail page so participants can connect.</p>
</div>
<!-- ── Summary tab ── -->
<div *ngIf="activeTab === 'summary' && responses.length > 0 && results">
<ng-container *ngFor="let qId of questionIds()">
<ion-card *ngIf="results.answers[qId] as qr">
<ion-card-header>
<ion-card-subtitle>{{ formatQuestionType(qr.type) }}</ion-card-subtitle>
<ion-card-title class="question-title">{{ qr.questionText }}</ion-card-title>
</ion-card-header>
<ion-card-content>
<!-- Multiple choice / yes_no: bar chart -->
<ng-container *ngIf="qr.tally">
<div
*ngFor="let entry of tallyEntries(qr.tally)"
class="bar-row">
<span class="bar-label">{{ entry.key }}</span>
<div class="bar-track">
<div
class="bar-fill"
[style.width.%]="tallyPercent(entry.count)">
</div>
</div>
<span class="bar-count">{{ entry.count }} ({{ tallyPercent(entry.count) }}%)</span>
</div>
</ng-container>
<!-- Text: list of all answers -->
<ng-container *ngIf="qr.texts">
<ion-list lines="inset">
<ion-item *ngFor="let text of qr.texts; let i = index">
<ion-label class="ion-text-wrap">
<p>{{ i + 1 }}. {{ text }}</p>
</ion-label>
</ion-item>
</ion-list>
<p *ngIf="qr.texts.length === 0" class="hint-text">No answers yet.</p>
</ng-container>
<!-- Rating: average + distribution -->
<ng-container *ngIf="qr.ratingDistribution">
<p class="rating-avg">
Average: <strong>{{ formatAvg(qr.ratingAvg) }}</strong> / 5
</p>
<div
*ngFor="let count of qr.ratingDistribution; let i = index"
class="bar-row">
<span class="bar-label">{{ ratingLabel(i) }} ★</span>
<div class="bar-track">
<div
class="bar-fill"
[style.width.%]="tallyPercent(count)">
</div>
</div>
<span class="bar-count">{{ count }}</span>
</div>
</ng-container>
</ion-card-content>
</ion-card>
</ng-container>
</div>
<!-- ── Individual tab ── -->
<div *ngIf="activeTab === 'individual' && responses.length > 0">
<ion-card *ngFor="let response of responses; let i = index">
<ion-card-header>
<ion-card-subtitle>Response #{{ i + 1 }} &bull; {{ response.submittedAt | date:'medium' }}</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<ion-list lines="none">
<ion-item *ngFor="let q of survey!.questions">
<ion-label class="ion-text-wrap">
<p class="question-text">{{ q.text }}</p>
<p class="answer-text">
<strong>{{ response.answers[q.id] ?? '(no answer)' }}</strong>
</p>
</ion-label>
</ion-item>
</ion-list>
</ion-card-content>
</ion-card>
</div>
</ion-content>

View File

@@ -0,0 +1,93 @@
.results-header {
padding-bottom: 0;
h2 {
font-size: 1.3rem;
font-weight: 600;
margin: 0 0 4px;
}
p {
color: var(--ion-color-medium);
margin: 0;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
ion-icon {
font-size: 56px;
color: var(--ion-color-medium);
margin-bottom: 12px;
}
}
.bar-row {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.bar-label {
min-width: 80px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-track {
flex: 1;
height: 16px;
background: var(--ion-color-light);
border-radius: 8px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--ion-color-primary);
border-radius: 8px;
transition: width 0.4s ease;
min-width: 2px;
}
.bar-count {
min-width: 70px;
font-size: 0.8rem;
color: var(--ion-color-medium);
text-align: right;
}
.question-title {
font-size: 1rem;
font-weight: 600;
white-space: normal;
}
.rating-avg {
font-size: 1rem;
margin-bottom: 12px;
}
.hint-text {
color: var(--ion-color-medium);
font-size: 0.875rem;
}
.question-text {
color: var(--ion-color-medium);
font-size: 0.85rem;
margin-bottom: 4px;
}
.answer-text {
font-size: 0.95rem;
}

View File

@@ -0,0 +1,145 @@
/**
* survey-results.page.ts
* Live results view for the survey creator.
*
* Uses Dexie liveQuery (via ResponseService) to automatically refresh
* whenever a new response is saved. Can also export responses as CSV.
*/
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastController } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { SurveyService } from '../../services/survey.service';
import { ResponseService } from '../../services/response.service';
import type { Survey, Response, SurveyResults } from '../../shared/models/survey.models';
@Component({
selector: 'app-survey-results',
templateUrl: './survey-results.page.html',
styleUrls: ['./survey-results.page.scss'],
standalone: false,
})
export class SurveyResultsPage implements OnInit, OnDestroy {
survey?: Survey;
responses: Response[] = [];
results?: SurveyResults;
/** Controls which tab is active: 'summary' | 'individual' */
activeTab: 'summary' | 'individual' = 'summary';
surveyId = '';
private responseSubscription?: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router,
private surveyService: SurveyService,
private responseService: ResponseService,
private toastCtrl: ToastController
) {}
async ngOnInit(): Promise<void> {
this.surveyId = this.route.snapshot.paramMap.get('id') ?? '';
if (!this.surveyId) {
this.router.navigate(['/home']);
return;
}
this.survey = await this.surveyService.getSurvey(this.surveyId);
if (!this.survey) {
this.router.navigate(['/home']);
return;
}
// Subscribe to live updates — results refresh whenever a new response arrives
this.responseSubscription = this.responseService
.getResponses$(this.surveyId)
.subscribe(async (responses) => {
this.responses = responses;
if (this.survey) {
this.results = await this.responseService.computeResults(this.survey);
}
});
}
ngOnDestroy(): void {
this.responseSubscription?.unsubscribe();
}
// -------------------------------------------------------------------------
// Helpers for the template
// -------------------------------------------------------------------------
/** Returns the keys of the results.answers object in question order */
questionIds(): string[] {
return this.survey?.questions.map((q) => q.id) ?? [];
}
/** Formats a rating average to one decimal place */
formatAvg(avg: number | undefined): string {
return avg != null ? avg.toFixed(1) : '';
}
/** Returns tally entries sorted by count (highest first) */
tallyEntries(tally: Record<string, number>): { key: string; count: number }[] {
return Object.entries(tally)
.map(([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count);
}
/** Percentage of total responses for a tally value */
tallyPercent(count: number): number {
if (this.responses.length === 0) return 0;
return Math.round((count / this.responses.length) * 100);
}
/** Returns the label for a rating index (1-based) */
ratingLabel(index: number): string {
return String(index + 1);
}
/** Converts snake_case question type to a readable label */
formatQuestionType(type: string): string {
const labels: Record<string, string> = {
text: 'Free Text',
multiple_choice: 'Multiple Choice',
yes_no: 'Yes / No',
rating: 'Rating',
};
return labels[type] ?? type;
}
// -------------------------------------------------------------------------
// CSV export
// -------------------------------------------------------------------------
exportCsv(): void {
if (!this.survey || this.responses.length === 0) return;
const questions = this.survey.questions;
const header = ['Submitted At', ...questions.map((q) => q.text)].join(',');
const rows = this.responses.map((r) => {
const values = questions.map((q) => {
const ans = r.answers[q.id] ?? '';
// Escape commas and newlines in text answers
const escaped = String(ans).replace(/"/g, '""');
return `"${escaped}"`;
});
return [`"${r.submittedAt}"`, ...values].join(',');
});
const csv = [header, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.survey.title.replace(/\s+/g, '_')}_results.csv`;
a.click();
URL.revokeObjectURL(url);
}
goBack(): void {
this.router.navigate(['/survey', this.surveyId]);
}
}

View File

@@ -0,0 +1,198 @@
/**
* peer.service.ts
* Angular service wrapping PeerJS for P2P WebRTC data channel communication.
*
* This service works for both the host (survey creator) and participants.
*
* IMPORTANT: All PeerJS event callbacks fire outside Angular's change-detection
* zone. Every callback body is wrapped in NgZone.run() to ensure the UI updates
* correctly after receiving P2P messages.
*/
import { Injectable, NgZone, OnDestroy } from '@angular/core';
import Peer, { DataConnection } from 'peerjs';
import { Subject, Observable } from 'rxjs';
/** Connection states used to drive UI feedback */
export type PeerConnectionState =
| 'idle'
| 'connecting'
| 'connected'
| 'disconnected'
| 'error';
@Injectable({ providedIn: 'root' })
export class PeerService implements OnDestroy {
private peer: Peer | null = null;
/** All active data connections, keyed by the remote peer ID */
private connections = new Map<string, DataConnection>();
// -------------------------------------------------------------------------
// Subjects exposed as Observables — components subscribe to these
// -------------------------------------------------------------------------
/** Emits every new incoming DataConnection (host side) */
private connectionSubject = new Subject<DataConnection>();
readonly onConnection$: Observable<DataConnection> =
this.connectionSubject.asObservable();
/** Emits when THIS peer disconnects from the PeerJS broker */
private disconnectSubject = new Subject<void>();
readonly onDisconnect$: Observable<void> =
this.disconnectSubject.asObservable();
/** Emits PeerJS errors */
private errorSubject = new Subject<Error>();
readonly onError$: Observable<Error> = this.errorSubject.asObservable();
/** Current peer ID assigned by the broker (null if not yet initialised) */
currentPeerId: string | null = null;
/** Observable connection state of this peer to the signaling broker */
private stateSubject = new Subject<PeerConnectionState>();
readonly state$: Observable<PeerConnectionState> =
this.stateSubject.asObservable();
constructor(private ngZone: NgZone) {}
// -------------------------------------------------------------------------
// Initialisation
// -------------------------------------------------------------------------
/**
* Create and open a PeerJS connection to the signaling broker.
*
* @param peerId Optional fixed peer ID.
* Hosts use `survey-{surveyId}` so participants can find them.
* Participants omit this to get a random ID.
* @returns The assigned peer ID.
*/
init(peerId?: string): Promise<string> {
// Destroy any previous instance before re-initialising
this.destroy();
return new Promise((resolve, reject) => {
this.ngZone.runOutsideAngular(() => {
this.peer = peerId ? new Peer(peerId) : new Peer();
this.peer.on('open', (id) => {
this.ngZone.run(() => {
this.currentPeerId = id;
this.stateSubject.next('connected');
resolve(id);
});
});
this.peer.on('connection', (conn) => {
this.ngZone.run(() => {
this.registerConnection(conn);
this.connectionSubject.next(conn);
});
});
this.peer.on('disconnected', () => {
this.ngZone.run(() => {
this.stateSubject.next('disconnected');
this.disconnectSubject.next();
});
});
this.peer.on('error', (err) => {
this.ngZone.run(() => {
this.stateSubject.next('error');
this.errorSubject.next(err as Error);
reject(err);
});
});
});
});
}
// -------------------------------------------------------------------------
// Outbound connections (participant side)
// -------------------------------------------------------------------------
/**
* Connect to a remote peer (the host).
* Returns the DataConnection immediately; caller should listen for
* the 'open' event before sending messages.
*
* @param remotePeerId The host's peer ID, e.g. `survey-{surveyId}`.
*/
connectTo(remotePeerId: string): DataConnection {
if (!this.peer) {
throw new Error('PeerService: call init() before connectTo()');
}
const conn = this.peer.connect(remotePeerId, { reliable: true });
this.registerConnection(conn);
return conn;
}
// -------------------------------------------------------------------------
// Sending data
// -------------------------------------------------------------------------
/**
* Send a message to a connected peer.
* The message is automatically serialised (PeerJS uses JSON by default).
*
* @param remotePeerId Target peer ID.
* @param data Any JSON-serialisable value.
*/
send(remotePeerId: string, data: unknown): void {
const conn = this.connections.get(remotePeerId);
if (conn && conn.open) {
conn.send(data);
}
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
/**
* Store a connection and set up common event listeners on it.
* Wraps all callbacks in NgZone.run() for change-detection safety.
*/
private registerConnection(conn: DataConnection): void {
this.connections.set(conn.peer, conn);
conn.on('close', () => {
this.ngZone.run(() => {
this.connections.delete(conn.peer);
});
});
conn.on('error', (err) => {
this.ngZone.run(() => {
console.error(`PeerService: connection error with ${conn.peer}`, err);
this.connections.delete(conn.peer);
});
});
}
// -------------------------------------------------------------------------
// Cleanup
// -------------------------------------------------------------------------
/**
* Close all connections and destroy the PeerJS instance.
* Call this when the host navigates away from the survey page.
*/
destroy(): void {
if (this.peer) {
this.connections.forEach((conn) => conn.close());
this.connections.clear();
this.peer.destroy();
this.peer = null;
this.currentPeerId = null;
this.stateSubject.next('idle');
}
}
ngOnDestroy(): void {
this.destroy();
}
}

View File

@@ -0,0 +1,181 @@
/**
* response.service.ts
* Read/write operations for survey responses and result aggregation.
*
* Responses are stored exclusively in the host's (creator's) IndexedDB.
* Participants never store response data locally.
*/
import { Injectable } from '@angular/core';
import { Observable, from } from 'rxjs';
import { liveQuery } from 'dexie';
import { db } from '../database/database';
import type {
Response,
Survey,
QuestionResult,
SurveyResults,
} from '../shared/models/survey.models';
@Injectable({ providedIn: 'root' })
export class ResponseService {
// -------------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------------
/**
* Save (insert or update) a participant's response.
* If a response for the given participantToken already exists, it is replaced.
* This handles both final submissions and draft updates.
*
* @param surveyId The survey being answered.
* @param participantToken The participant's unique token.
* @param answers Map from questionId to answer value.
*/
async saveResponse(
surveyId: string,
participantToken: string,
answers: Record<string, unknown>
): Promise<void> {
// Check for existing response to preserve the original submission time
const existing = await db.responses
.where('participantToken')
.equals(participantToken)
.first();
const now = new Date().toISOString();
if (existing) {
await db.responses.update(existing.id, {
answers,
updatedAt: now,
});
} else {
const response: Response = {
id: crypto.randomUUID(),
surveyId,
participantToken,
answers,
submittedAt: now,
};
await db.responses.add(response);
}
}
// -------------------------------------------------------------------------
// Reading responses
// -------------------------------------------------------------------------
/** Get all responses for a survey (one-time async read) */
async getResponses(surveyId: string): Promise<Response[]> {
return db.responses.where('surveyId').equals(surveyId).toArray();
}
/**
* Reactive stream of all responses for a survey.
* Automatically re-emits whenever a new response is saved or updated.
* Use this on the results page for real-time updates.
*/
getResponses$(surveyId: string): Observable<Response[]> {
return from(
liveQuery(() =>
db.responses.where('surveyId').equals(surveyId).toArray()
)
);
}
/** Get the single response submitted for a given participant token */
async getResponseByToken(participantToken: string): Promise<Response | undefined> {
return db.responses
.where('participantToken')
.equals(participantToken)
.first();
}
// -------------------------------------------------------------------------
// Result aggregation
// -------------------------------------------------------------------------
/**
* Compute aggregated results for a survey.
* This is computed on demand — the aggregation is not stored anywhere.
*
* @param survey The full survey definition (needed for question metadata).
* @returns Aggregated SurveyResults object.
*/
async computeResults(survey: Survey): Promise<SurveyResults> {
const responses = await this.getResponses(survey.id);
const results: SurveyResults = {
surveyId: survey.id,
totalResponses: responses.length,
answers: {},
};
for (const question of survey.questions) {
const questionResult: QuestionResult = {
questionId: question.id,
questionText: question.text,
type: question.type,
};
// Collect the answer for this question from every response
const rawAnswers = responses
.map((r) => r.answers[question.id])
.filter((a) => a !== undefined && a !== null && a !== '');
switch (question.type) {
case 'multiple_choice': {
// Count how many times each option was selected
const tally: Record<string, number> = {};
// Initialise all options to 0 so even unselected options appear
(question.options ?? []).forEach((opt) => (tally[opt] = 0));
rawAnswers.forEach((a) => {
const key = String(a);
tally[key] = (tally[key] ?? 0) + 1;
});
questionResult.tally = tally;
break;
}
case 'yes_no': {
const tally: Record<string, number> = { Yes: 0, No: 0 };
rawAnswers.forEach((a) => {
const key = a === true || a === 'Yes' ? 'Yes' : 'No';
tally[key]++;
});
questionResult.tally = tally;
break;
}
case 'text': {
questionResult.texts = rawAnswers.map(String);
break;
}
case 'rating': {
const nums = rawAnswers.map(Number).filter((n) => !isNaN(n));
if (nums.length > 0) {
questionResult.ratingAvg =
nums.reduce((acc, n) => acc + n, 0) / nums.length;
// distribution[0] = count of rating 1, ..., distribution[4] = count of rating 5
const dist = [0, 0, 0, 0, 0];
nums.forEach((n) => {
const idx = Math.round(n) - 1;
if (idx >= 0 && idx <= 4) {
dist[idx]++;
}
});
questionResult.ratingDistribution = dist;
}
break;
}
}
results.answers[question.id] = questionResult;
}
return results;
}
}

View File

@@ -0,0 +1,160 @@
/**
* survey.service.ts
* CRUD operations for surveys and participant tokens using Dexie (IndexedDB).
*
* The service exposes both async methods (for one-time reads/writes) and
* Observable streams powered by Dexie's liveQuery for reactive UI updates.
*/
import { Injectable } from '@angular/core';
import { Observable, from } from 'rxjs';
import { liveQuery } from 'dexie';
import { db } from '../database/database';
import type { Survey, Question, Participant } from '../shared/models/survey.models';
@Injectable({ providedIn: 'root' })
export class SurveyService {
// -------------------------------------------------------------------------
// Survey CRUD
// -------------------------------------------------------------------------
/**
* Reactive stream of all surveys, ordered newest-first.
* Automatically emits whenever the surveys table changes.
*/
getAllSurveys$(): Observable<Survey[]> {
return from(
liveQuery(() =>
db.surveys.orderBy('createdAt').reverse().toArray()
)
);
}
/** Retrieve a single survey by its UUID */
async getSurvey(id: string): Promise<Survey | undefined> {
return db.surveys.get(id);
}
/**
* Create a new survey and persist it to IndexedDB.
* Assigns a UUID and sets default values.
*
* @param data Partial survey — title and questions are required.
* @returns The fully populated Survey object.
*/
async createSurvey(data: {
title: string;
description?: string;
questions: Question[];
showResultsToParticipants?: boolean;
}): Promise<Survey> {
const survey: Survey = {
id: crypto.randomUUID(),
title: data.title,
description: data.description,
questions: data.questions,
createdAt: new Date().toISOString(),
showResultsToParticipants: data.showResultsToParticipants ?? false,
status: 'draft',
};
await db.surveys.add(survey);
return survey;
}
/**
* Partially update an existing survey.
* Pass only the fields you want to change.
*/
async updateSurvey(id: string, patch: Partial<Omit<Survey, 'id'>>): Promise<void> {
await db.surveys.update(id, patch);
}
/**
* Delete a survey and all associated participants and responses.
* Once deleted the data is gone — there is no undo.
*/
async deleteSurvey(id: string): Promise<void> {
await db.transaction('rw', [db.surveys, db.participants, db.responses], async () => {
// Remove all participant tokens for this survey
await db.participants.where('surveyId').equals(id).delete();
// Remove all responses for this survey
await db.responses.where('surveyId').equals(id).delete();
// Remove the survey itself
await db.surveys.delete(id);
});
}
// -------------------------------------------------------------------------
// Participant token management
// -------------------------------------------------------------------------
/**
* Generate `count` unique participant tokens for the given survey and
* store them in IndexedDB.
*
* Each token becomes the identity embedded in a shareable link.
* Tokens are NOT locked until the participant clicks "Submit".
*
* @returns The array of newly created Participant records.
*/
async generateParticipantTokens(
surveyId: string,
count: number
): Promise<Participant[]> {
const participants: Participant[] = Array.from({ length: count }, () => ({
token: crypto.randomUUID(),
surveyId,
locked: false,
}));
await db.participants.bulkAdd(participants);
return participants;
}
/**
* Retrieve all participant tokens for a survey (ordered by insertion).
*/
async getParticipants(surveyId: string): Promise<Participant[]> {
return db.participants.where('surveyId').equals(surveyId).toArray();
}
/**
* Reactive stream of participants for a survey.
* Emits whenever a participant's status changes (e.g. locked).
*/
getParticipants$(surveyId: string): Observable<Participant[]> {
return from(
liveQuery(() =>
db.participants.where('surveyId').equals(surveyId).toArray()
)
);
}
/** Look up a single participant by token */
async getParticipant(token: string): Promise<Participant | undefined> {
return db.participants.get(token);
}
/**
* Mark a participant token as "used" (first connection received).
* Does NOT lock the token — the participant can still update answers.
*/
async markParticipantUsed(token: string): Promise<void> {
await db.participants.update(token, { usedAt: new Date().toISOString() });
}
/**
* Lock a participant token after a final submission.
* Any subsequent join attempts with this token will be rejected.
*/
async lockParticipantToken(token: string): Promise<void> {
await db.participants.update(token, { locked: true });
}
/**
* Update the optional human-readable label for a participant token.
*/
async updateParticipantLabel(token: string, label: string): Promise<void> {
await db.participants.update(token, { label });
}
}

View File

@@ -0,0 +1,137 @@
/**
* survey.models.ts
* Central TypeScript interfaces and types for the P2P Survey App.
* All data structures for surveys, participants, responses, and
* the P2P wire protocol are defined here.
*/
// ---------------------------------------------------------------------------
// Core data models (stored in IndexedDB)
// ---------------------------------------------------------------------------
/** A single question in a survey */
export interface Question {
/** Unique identifier (UUID) */
id: string;
/** Question text displayed to participants */
text: string;
/** Type of question determines the input widget */
type: 'multiple_choice' | 'text' | 'rating' | 'yes_no';
/** Answer options — only used when type === 'multiple_choice' */
options?: string[];
/** Whether the participant must answer this question before submitting */
required: boolean;
}
/** A complete survey definition created by the host */
export interface Survey {
/** Unique identifier (UUID) */
id: string;
/** Short title shown in lists and headings */
title: string;
/** Optional longer description shown to participants */
description?: string;
/** Ordered list of questions */
questions: Question[];
/** When the survey was created (stored as ISO string in IndexedDB) */
createdAt: string;
/**
* If true, participants who submit their answers will receive
* an aggregated results summary via the P2P data channel.
*/
showResultsToParticipants: boolean;
/** Lifecycle state of the survey */
status: 'draft' | 'active' | 'closed';
}
/**
* A pre-generated participation token tied to exactly one survey.
* Each unique share link contains one of these tokens.
* The primary key is `token` (UUID).
*/
export interface Participant {
/** UUID that is embedded in the unique share link */
token: string;
/** The survey this token belongs to */
surveyId: string;
/** Optional human-readable label (e.g. "Alice", "Respondent 3") */
label?: string;
/** When this token was first used to connect */
usedAt?: string;
/**
* True once the participant has clicked "Submit".
* Locked tokens reject any further submissions from that link.
*/
locked: boolean;
}
/** A participant's answers for one survey — stored by the host in IndexedDB */
export interface Response {
/** Unique identifier (UUID) */
id: string;
/** Which survey this response belongs to */
surveyId: string;
/** The participant token that submitted this response */
participantToken: string;
/** Map from questionId → answer value */
answers: Record<string, unknown>;
/** When the participant clicked "Submit" */
submittedAt: string;
/** Updated whenever the participant sends a draft update */
updatedAt?: string;
}
// ---------------------------------------------------------------------------
// Aggregated results (computed on-the-fly, not stored)
// ---------------------------------------------------------------------------
/** Aggregated result for a single question */
export interface QuestionResult {
questionId: string;
questionText: string;
type: Question['type'];
/** For 'multiple_choice' and 'yes_no': count per option string */
tally?: Record<string, number>;
/** For 'text': array of all free-text answers */
texts?: string[];
/** For 'rating': arithmetic mean of all numeric answers */
ratingAvg?: number;
/** For 'rating': counts per rating value [index 0 = rating 1, ..., index 4 = rating 5] */
ratingDistribution?: number[];
}
/** Aggregated results for an entire survey */
export interface SurveyResults {
surveyId: string;
totalResponses: number;
/** Map from questionId → aggregated result */
answers: Record<string, QuestionResult>;
}
// ---------------------------------------------------------------------------
// P2P wire protocol — discriminated union of all messages
// ---------------------------------------------------------------------------
/**
* Every message exchanged over the PeerJS data channel must conform
* to one of these shapes. The `type` field is the discriminant.
*
* Flow:
* Participant → Host : join, submit, update
* Host → Participant : survey, results, ack, error
*/
export type P2PMessage =
/** Participant identifies itself to the host and requests the survey */
| { type: 'join'; token: string }
/** Host sends the survey definition to the participant */
| { type: 'survey'; data: Survey; showResults: boolean }
/** Participant submits final answers — host will lock the token */
| { type: 'submit'; token: string; answers: Record<string, unknown> }
/** Participant saves a draft — host stores answers but does NOT lock token */
| { type: 'update'; token: string; answers: Record<string, unknown> }
/** Host pushes aggregated results to participant (if showResults is true) */
| { type: 'results'; data: SurveyResults }
/** Host acknowledges a successful submit or update */
| { type: 'ack'; status: 'submitted' | 'updated' }
/** Host signals an error — participant should display the reason */
| { type: 'error'; reason: 'invalid_token' | 'already_submitted' | 'survey_not_found' | 'survey_closed' | 'survey_draft' };

BIN
src/assets/icon/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

1
src/assets/shapes.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="350" height="140" xmlns="http://www.w3.org/2000/svg" style="background:#f6f7f9"><g fill="none" fill-rule="evenodd"><path fill="#F04141" style="mix-blend-mode:multiply" d="M61.905-34.23l96.194 54.51-66.982 54.512L22 34.887z"/><circle fill="#10DC60" style="mix-blend-mode:multiply" cx="155.5" cy="135.5" r="57.5"/><path fill="#3880FF" style="mix-blend-mode:multiply" d="M208.538 9.513l84.417 15.392L223.93 93.93z"/><path fill="#FFCE00" style="mix-blend-mode:multiply" d="M268.625 106.557l46.332-26.75 46.332 26.75v53.5l-46.332 26.75-46.332-26.75z"/><circle fill="#7044FF" style="mix-blend-mode:multiply" cx="299.5" cy="9.5" r="38.5"/><rect fill="#11D3EA" style="mix-blend-mode:multiply" transform="rotate(-60 148.47 37.886)" x="143.372" y="-7.056" width="10.196" height="89.884" rx="5.098"/><path d="M-25.389 74.253l84.86 8.107c5.498.525 9.53 5.407 9.004 10.905a10 10 0 0 1-.057.477l-12.36 85.671a10.002 10.002 0 0 1-11.634 8.42l-86.351-15.226c-5.44-.959-9.07-6.145-8.112-11.584l13.851-78.551a10 10 0 0 1 10.799-8.219z" fill="#7044FF" style="mix-blend-mode:multiply"/><circle fill="#0CD1E8" style="mix-blend-mode:multiply" cx="273.5" cy="106.5" r="20.5"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
export const environment = {
production: true
};

View File

@@ -0,0 +1,16 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.

37
src/global.scss Normal file
View File

@@ -0,0 +1,37 @@
/*
* App Global CSS
* ----------------------------------------------------------------------------
* Put style rules here that you want to apply globally. These styles are for
* the entire app and not just one component. Additionally, this file can be
* used as an entry point to import other CSS/Sass files to be included in the
* output CSS.
* For more information on global stylesheets, visit the documentation:
* https://ionicframework.com/docs/layout/global-stylesheets
*/
/* Core CSS required for Ionic components to work properly */
@import "@ionic/angular/css/core.css";
/* Basic CSS for apps built with Ionic */
@import "@ionic/angular/css/normalize.css";
@import "@ionic/angular/css/structure.css";
@import "@ionic/angular/css/typography.css";
@import "@ionic/angular/css/display.css";
/* Optional CSS utils that can be commented out */
@import "@ionic/angular/css/padding.css";
@import "@ionic/angular/css/float-elements.css";
@import "@ionic/angular/css/text-alignment.css";
@import "@ionic/angular/css/text-transformation.css";
@import "@ionic/angular/css/flex-utils.css";
/**
* Ionic Dark Mode
* -----------------------------------------------------
* For more info, please see:
* https://ionicframework.com/docs/theming/dark-mode
*/
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
@import "@ionic/angular/css/palettes/dark.system.css";

26
src/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Ionic App</title>
<base href="/" />
<meta name="color-scheme" content="light dark" />
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="icon" type="image/png" href="assets/icon/favicon.png" />
<!-- add to homescreen for ios -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</head>
<body>
<app-root></app-root>
</body>
</html>

6
src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.log(err));

55
src/polyfills.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes recent versions of Safari, Chrome (including
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
import './zone-flags';
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

5
src/serve.json Normal file
View File

@@ -0,0 +1,5 @@
{
"rewrites": [
{ "source": "/**", "destination": "/index.html" }
]
}

14
src/test.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);

2
src/theme/variables.scss Normal file
View File

@@ -0,0 +1,2 @@
// For information on how to create your own theme, please refer to:
// https://ionicframework.com/docs/theming/

6
src/zone-flags.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Prevents Angular change detection from
* running with certain Web Component callbacks
*/
// eslint-disable-next-line no-underscore-dangle
(window as any).__Zone_disable_customElements = true;

15
tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.d.ts"
]
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "es2022",
"module": "es2020",
"lib": [
"es2018",
"dom"
],
"skipLibCheck": true,
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

18
tsconfig.spec.json Normal file
View File

@@ -0,0 +1,18 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}