This commit is contained in:
Dimas Wiese
2026-03-15 23:34:23 +01:00
parent 4275cbd795
commit 6b0f87199c
63 changed files with 22786 additions and 0 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": {}
}
]
}

70
.gitignore vendored Normal file
View File

@@ -0,0 +1,70 @@
# 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

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"]
}

149
angular.json Normal file
View File

@@ -0,0 +1,149 @@
{
"$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"
}
],
"styles": ["src/global.scss", "src/theme/variables.scss"],
"scripts": []
},
"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"
]
},
"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;

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

@@ -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> = {};
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,263 @@
/**
* 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'
): 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.';
}
}
}

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,166 @@
<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()">
<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,318 @@
/**
* 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, AlertController, Platform } 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,
private alertCtrl: AlertController,
private platform: Platform
) {}
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
// -------------------------------------------------------------------------
/** Toggle and persist the showResultsToParticipants flag */
async toggleShowResults(): Promise<void> {
if (!this.survey) return;
const newValue = !this.survey.showResultsToParticipants;
await this.surveyService.updateSurvey(this.surveyId, {
showResultsToParticipants: newValue,
});
this.survey.showResultsToParticipants = newValue;
}
async changeSurveyStatus(status: Survey['status']): Promise<void> {
if (!this.survey) return;
await this.surveyService.updateSurvey(this.surveyId, { status });
this.survey.status = 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') {
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' };

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
*/

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"
]
}