Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf2833683 | ||
|
|
fb3f0ba800 | ||
|
|
9c9114879d | ||
|
|
2de09383b9 | ||
|
|
9a77665593 | ||
|
|
d22c8845a6 | ||
|
|
a508c42694 | ||
|
|
7ce139c310 | ||
|
|
edc003ff49 | ||
|
|
598e071bd6 | ||
|
|
11924df19e | ||
|
|
237d995e16 | ||
|
|
3a105097c3 | ||
|
|
7702efc165 | ||
|
|
17afa909a9 | ||
|
|
3b042fa4f5 | ||
|
|
08ee84e5c9 | ||
|
|
c0549de4dd | ||
|
|
4dd5ce6f5f | ||
|
|
9e99fc5511 | ||
|
|
e73b68e8d2 | ||
|
|
82e16e2598 | ||
|
|
58c13aa86c | ||
|
|
52282705b3 | ||
|
|
3e5c1c4588 | ||
|
|
43083c1927 | ||
|
|
6f897b263a | ||
|
|
a580cdf571 | ||
|
|
398964b8ab | ||
|
|
7a16c1f716 | ||
|
|
322b11c9bd | ||
|
|
b162a8512a | ||
|
|
afc51e60de | ||
|
|
0ad7aea819 | ||
|
|
789ff38b8c | ||
|
|
e29d54e3de | ||
|
|
a873c6b6f9 | ||
|
|
7c05fb1944 | ||
|
|
0769892aba | ||
|
|
5019b7bba8 | ||
|
|
3c394a1823 | ||
|
|
0fa7368a92 | ||
|
|
cb5f440d02 | ||
|
|
257ccab43d | ||
|
|
59585328b2 | ||
|
|
180a9540d8 | ||
|
|
1471f0cc24 | ||
|
|
e69572c847 | ||
|
|
608874a11e | ||
|
|
3b1a95c98a | ||
|
|
99fc952dd1 | ||
|
|
7db0615f20 | ||
|
|
ca9477ad9e | ||
|
|
3d9fd8c334 | ||
|
|
95646daace | ||
|
|
bacab0af5b | ||
|
|
4dd56650d8 | ||
|
|
3302bc77a2 |
No files matched your search
@@ -0,0 +1,37 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# Agent Rules and Verification Constraints
|
||||
|
||||
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
|
||||
|
||||
1. **Test Coverage (>= 80%)**:
|
||||
- The overall project code coverage (Statements, Lines, and Functions) must be maintained at **80% or higher**.
|
||||
- Verify coverage by running:
|
||||
```bash
|
||||
bun run test --watch=false --coverage
|
||||
```
|
||||
- If your changes introduce new code or modify existing code in a way that drops the coverage below 80%, you must write appropriate unit tests in `.spec.ts` files to bring it back up.
|
||||
|
||||
2. **Code Formatting (Prettier)**:
|
||||
- All code files must be correctly formatted according to the project's Prettier config before finishing.
|
||||
- Run the formatter with:
|
||||
```bash
|
||||
bun run format
|
||||
```
|
||||
|
||||
3. **Successful Build**:
|
||||
- The project must always build successfully for production without compilation or bundling errors before ending.
|
||||
- Verify the build with:
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
4. **GitLab Pipeline Checks**:
|
||||
- You must execute the local equivalent of the GitLab CI/CD pipeline steps before completing your task. All of the following commands must pass:
|
||||
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
|
||||
- **Formatting check**: `bun run format:check`
|
||||
- **TypeScript Type check**: `bunx tsc --noEmit`
|
||||
- **Unit Tests and Coverage**: `bun run test --watch=false --coverage`
|
||||
- **Production Build**: `bun run build -- --configuration production`
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Rules and Verification Constraints
|
||||
|
||||
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
|
||||
|
||||
1. **Test Coverage (>= 80%)**:
|
||||
- The overall project code coverage (Statements, Lines, and Functions) must be maintained at **80% or higher**.
|
||||
- Verify coverage by running:
|
||||
```bash
|
||||
bun run test --watch=false --coverage
|
||||
```
|
||||
- If your changes introduce new code or modify existing code in a way that drops the coverage below 80%, you must write appropriate unit tests in `.spec.ts` files to bring it back up.
|
||||
|
||||
2. **Code Formatting (Prettier)**:
|
||||
- All code files must be correctly formatted according to the project's Prettier config before finishing.
|
||||
- Run the formatter with:
|
||||
```bash
|
||||
bun run format
|
||||
```
|
||||
|
||||
3. **Successful Build**:
|
||||
- The project must always build successfully for production without compilation or bundling errors before ending.
|
||||
- Verify the build with:
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
4. **GitLab Pipeline Checks**:
|
||||
- You must execute the local equivalent of the GitLab CI/CD pipeline steps before completing your task. All of the following commands must pass:
|
||||
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
|
||||
- **Formatting check**: `bun run format:check`
|
||||
- **TypeScript Type check**: `bunx tsc --noEmit`
|
||||
- **Unit Tests and Coverage**: `bun run test --watch=false --coverage`
|
||||
- **Production Build**: `bun run build -- --configuration production`
|
||||
@@ -1,47 +0,0 @@
|
||||
You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices.
|
||||
|
||||
## TypeScript Best Practices
|
||||
|
||||
- Use strict type checking
|
||||
- Prefer type inference when the type is obvious
|
||||
- Avoid the `any` type; use `unknown` when type is uncertain
|
||||
|
||||
## Angular Best Practices
|
||||
|
||||
- Always use standalone components over NgModules
|
||||
- Must NOT set `standalone: true` inside Angular decorators. It's the default.
|
||||
- Use signals for state management
|
||||
- Implement lazy loading for feature routes
|
||||
- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
|
||||
- Use `NgOptimizedImage` for all static images.
|
||||
- `NgOptimizedImage` does not work for inline base64 images.
|
||||
|
||||
## Components
|
||||
|
||||
- Keep components small and focused on a single responsibility
|
||||
- Use `input()` and `output()` functions instead of decorators
|
||||
- Use `computed()` for derived state
|
||||
- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
|
||||
- Prefer inline templates for small components
|
||||
- Prefer Reactive forms instead of Template-driven ones
|
||||
- Do NOT use `ngClass`, use `class` bindings instead
|
||||
- Do NOT use `ngStyle`, use `style` bindings instead
|
||||
|
||||
## State Management
|
||||
|
||||
- Use signals for local component state
|
||||
- Use `computed()` for derived state
|
||||
- Keep state transformations pure and predictable
|
||||
- Do NOT use `mutate` on signals, use `update` or `set` instead
|
||||
|
||||
## Templates
|
||||
|
||||
- Keep templates simple and avoid complex logic
|
||||
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
|
||||
- Use the async pipe to handle observables
|
||||
|
||||
## Services
|
||||
|
||||
- Design services around a single responsibility
|
||||
- Use the `providedIn: 'root'` option for singleton services
|
||||
- Use the `inject()` function instead of constructor injection
|
||||
@@ -1,34 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for npm
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'monday'
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
angular:
|
||||
patterns:
|
||||
- '@angular/*'
|
||||
development-dependencies:
|
||||
dependency-type: 'development'
|
||||
update-types:
|
||||
- 'minor'
|
||||
- 'patch'
|
||||
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'monday'
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'monday'
|
||||
open-pull-requests-limit: 5
|
||||
@@ -1,41 +0,0 @@
|
||||
name: CodeQL Security Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 0 * * 1' # Run every Monday at midnight UTC
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze Code
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript-typescript']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
@@ -1,28 +0,0 @@
|
||||
name: Dependabot Auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
name: Auto-merge Dependabot minor and patch updates
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v2.5.0
|
||||
|
||||
- name: Auto-merge minor and patch updates
|
||||
if: |
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-patch'
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@0.34.2
|
||||
with:
|
||||
image-ref: ghcr.io/thoguet/vac-optimizer@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
@@ -1,105 +0,0 @@
|
||||
name: PR Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Check code formatting with Prettier
|
||||
run: npx prettier --check "src/**/*.{ts,html,scss,css,json}"
|
||||
|
||||
- name: Run ESLint
|
||||
run: npx eslint "src/**/*.ts"
|
||||
|
||||
type-check:
|
||||
name: TypeScript Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Run TypeScript compiler
|
||||
run: npx tsc --noEmit
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test -- --watch=false --browsers=ChromeHeadless --code-coverage
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@v5
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage/vac-optimizer/lcov.info
|
||||
fail_ci_if_error: false
|
||||
|
||||
build:
|
||||
name: Build Application
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build production bundle
|
||||
run: npm run build -- --configuration production
|
||||
env:
|
||||
NG_BUILD_CACHE: '.angular/cache'
|
||||
|
||||
- name: Check bundle size
|
||||
run: |
|
||||
du -sh dist/vac-optimizer/browser
|
||||
echo "Build completed successfully"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
@@ -41,3 +41,6 @@ __screenshots__/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Auto-generated i18n files
|
||||
/src/app/i18n-registry.ts
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
image: oven/bun:latest
|
||||
|
||||
stages:
|
||||
- validate
|
||||
- test
|
||||
- build
|
||||
- security
|
||||
- docker
|
||||
|
||||
variables:
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: ''
|
||||
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- bun.lock
|
||||
paths:
|
||||
- .bun/
|
||||
|
||||
.node_setup:
|
||||
before_script:
|
||||
- bun install --frozen-lockfile
|
||||
|
||||
# -- Validate Stage --
|
||||
|
||||
lint:
|
||||
extends: .node_setup
|
||||
stage: validate
|
||||
script:
|
||||
- bunx eslint "src/**/*.ts"
|
||||
|
||||
prettier:
|
||||
extends: .node_setup
|
||||
stage: validate
|
||||
script:
|
||||
- bunx prettier --check "src/**/*.{ts,html,scss,css,json}"
|
||||
|
||||
type-check:
|
||||
extends: .node_setup
|
||||
stage: validate
|
||||
script:
|
||||
- bunx tsc --noEmit
|
||||
|
||||
# -- Test Stage --
|
||||
|
||||
test:
|
||||
extends: .node_setup
|
||||
stage: test
|
||||
script:
|
||||
- bun run test --watch=false --coverage
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- coverage/
|
||||
reports:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage/cobertura-coverage.xml
|
||||
expire_in: 7 days
|
||||
|
||||
# -- Build Stage --
|
||||
|
||||
build:
|
||||
image: oven/bun:latest
|
||||
extends: .node_setup
|
||||
stage: build
|
||||
script:
|
||||
- apt-get update && apt-get install -y nodejs
|
||||
- bun run build -- --configuration production
|
||||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
expire_in: 7 days
|
||||
|
||||
# -- Security Stage --
|
||||
|
||||
include:
|
||||
- template: Security/SAST.gitlab-ci.yml
|
||||
|
||||
trivy_scan:
|
||||
stage: security
|
||||
image:
|
||||
name: aquasec/trivy:latest
|
||||
entrypoint: ['']
|
||||
before_script: [] # override global before_script
|
||||
script:
|
||||
# Trivy will scan the project directory
|
||||
- trivy fs --format sarif --output trivy-results.sarif .
|
||||
artifacts:
|
||||
reports:
|
||||
sast: trivy-results.sarif
|
||||
expire_in: 7 days
|
||||
|
||||
# -- Docker Stage --
|
||||
|
||||
docker-build:
|
||||
stage: docker
|
||||
image: docker:24.0.5
|
||||
services:
|
||||
- docker:24.0.5-dind
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
||||
script:
|
||||
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" -t "$CI_REGISTRY_IMAGE:latest" .
|
||||
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
|
||||
- docker push "$CI_REGISTRY_IMAGE:latest"
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
+12
-9
@@ -1,32 +1,35 @@
|
||||
# Stage 1: Build the Angular application
|
||||
FROM node:25-alpine AS builder
|
||||
FROM oven/bun:alpine AS builder
|
||||
|
||||
# Install nodejs to support Angular prerendering worker threads
|
||||
RUN apk add --no-cache nodejs
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY package.json bun.lock* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production=false
|
||||
RUN bun install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application for production
|
||||
RUN npm run build
|
||||
RUN bun run build
|
||||
|
||||
# Stage 2: Production image
|
||||
FROM node:25-alpine AS production
|
||||
FROM oven/bun:alpine AS production
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY package.json bun.lock* ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
RUN bun install --production
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
@@ -39,7 +42,7 @@ EXPOSE 4000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
CMD bun -e "require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/vac-optimizer/server/server.mjs"]
|
||||
CMD ["bun", "run", "dist/vac-optimizer/server/server.mjs"]
|
||||
@@ -7,7 +7,7 @@ This project was generated using [Angular CLI](https://github.com/angular/angula
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
bun start
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
@@ -21,13 +21,13 @@ This project uses ESLint and Prettier to maintain code quality and consistency.
|
||||
Check for linting errors:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
bun run lint
|
||||
```
|
||||
|
||||
Auto-fix linting issues:
|
||||
|
||||
```bash
|
||||
npm run lint:fix
|
||||
bun run lint:fix
|
||||
```
|
||||
|
||||
### Formatting
|
||||
@@ -35,13 +35,13 @@ npm run lint:fix
|
||||
Check code formatting:
|
||||
|
||||
```bash
|
||||
npm run format:check
|
||||
bun run format:check
|
||||
```
|
||||
|
||||
Format code:
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
bun run format
|
||||
```
|
||||
|
||||
## Code scaffolding
|
||||
@@ -63,17 +63,17 @@ ng generate --help
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
bun run build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
To execute unit tests, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
bun run test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
+10
-15
@@ -26,16 +26,22 @@
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"server": "src/main.server.ts",
|
||||
"outputMode": "server",
|
||||
"ssr": {
|
||||
"entry": "src/server.ts"
|
||||
},
|
||||
"prerender": false,
|
||||
"allowedCommonJsDependencies": [
|
||||
"deepmerge",
|
||||
"moment-timezone",
|
||||
"jalaali-js"
|
||||
"jalaali-js",
|
||||
"dayjs",
|
||||
"dayjs/plugin/weekOfYear",
|
||||
"dayjs/plugin/isoWeek"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
@@ -43,7 +49,7 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2MB",
|
||||
"maximumWarning": "2.5MB",
|
||||
"maximumError": "3MB"
|
||||
},
|
||||
{
|
||||
@@ -81,18 +87,7 @@
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.scss"]
|
||||
}
|
||||
"builder": "@analogjs/vitest-angular:test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ services:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'node',
|
||||
'bun',
|
||||
'-e',
|
||||
"require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
|
||||
]
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ services:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'node',
|
||||
'bun',
|
||||
'-e',
|
||||
"require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
|
||||
]
|
||||
|
||||
Generated
-14856
File diff suppressed because it is too large.
Load diff
+46
-40
@@ -3,15 +3,15 @@
|
||||
"version": "1.5.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"serve:ssr:vac-optimizer": "node dist/vac-optimizer/server/server.mjs",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"lint:fix": "eslint \"src/**/*.ts\" --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,html,scss,css,json}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,html,scss,css,json}\""
|
||||
"start": "bun scripts/generate-langs.js && ng serve",
|
||||
"build": "bun scripts/generate-langs.js && ng build",
|
||||
"watch": "bun scripts/generate-langs.js && ng build --watch --configuration development",
|
||||
"test": "bun scripts/generate-langs.js && ng test",
|
||||
"serve:ssr:vac-optimizer": "bun dist/vac-optimizer/server/server.mjs",
|
||||
"lint": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\"",
|
||||
"lint:fix": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\" --fix",
|
||||
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\"",
|
||||
"format:check": "bun scripts/generate-langs.js && prettier --check \"src/**/*.{ts,html,scss,css,json}\""
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
@@ -27,45 +27,51 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "21.2.0",
|
||||
"@angular/cdk": "^21.2.0",
|
||||
"@angular/common": "21.2.0",
|
||||
"@angular/compiler": "21.2.0",
|
||||
"@angular/core": "21.2.0",
|
||||
"@angular/forms": "21.2.0",
|
||||
"@angular/material": "^21.2.0",
|
||||
"@angular/platform-browser": "21.2.0",
|
||||
"@angular/platform-server": "21.2.0",
|
||||
"@angular/router": "21.2.0",
|
||||
"@angular/ssr": "21.2.0",
|
||||
"date-holidays": "^3.26.8",
|
||||
"dayjs": "^1.11.19",
|
||||
"@angular/animations": "21.2.15",
|
||||
"@angular/cdk": "^21.2.13",
|
||||
"@angular/common": "21.2.15",
|
||||
"@angular/compiler": "21.2.15",
|
||||
"@angular/core": "21.2.15",
|
||||
"@angular/forms": "21.2.15",
|
||||
"@angular/material": "^21.2.13",
|
||||
"@angular/platform-browser": "21.2.15",
|
||||
"@angular/platform-browser-dynamic": "21.2.15",
|
||||
"@angular/platform-server": "21.2.15",
|
||||
"@angular/router": "21.2.15",
|
||||
"@angular/ssr": "21.2.13",
|
||||
"date-holidays": "^3.30.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"express": "^5.2.1",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "21.2.0",
|
||||
"@angular/cli": "21.2.0",
|
||||
"@angular/compiler-cli": "21.2.0",
|
||||
"@analogjs/platform": "^2.5.2",
|
||||
"@analogjs/vite-plugin-angular": "^2.5.2",
|
||||
"@analogjs/vitest-angular": "^2.5.2",
|
||||
"@angular/build": "21.2.13",
|
||||
"@angular/cli": "21.2.13",
|
||||
"@angular/compiler-cli": "21.2.15",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nx/devkit": "^22.7.5",
|
||||
"@nx/vite": "^22.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jasmine": "~6.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"eslint": "^10.0.2",
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitest/coverage-istanbul": "^4.1.7",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"@vitest/ui": "^4.0.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"jasmine-core": "~6.1.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-preset-angular": "^16.1.1",
|
||||
"karma": "~6.4.4",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.1",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1"
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"jest": "^30.4.2",
|
||||
"jest-preset-angular": "^16.1.5",
|
||||
"jsdom": "^22.0.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import "@angular/compiler";
|
||||
import { CalendarService, SelectedDates, DayType, SelectableDayType } from './src/app/services/calendar-service';
|
||||
import { DateCacheService } from './src/app/services/date-cache.service';
|
||||
import { RuleService } from './src/app/services/rule-service';
|
||||
|
||||
const cs = new CalendarService();
|
||||
cs['dateCache'] = {
|
||||
getTodayComponents: () => {
|
||||
const d = new Date();
|
||||
return { date: d.getDate(), month: d.getMonth(), year: d.getFullYear() };
|
||||
}
|
||||
} as any;
|
||||
|
||||
const sd = cs.getSelectedDatesForPeriod(new Date(), new Date(new Date().setFullYear(new Date().getFullYear() + 1)));
|
||||
|
||||
console.log("Selected dates length:", sd._datesSelected.length);
|
||||
|
||||
const rules = [
|
||||
{
|
||||
id: 'default-samedi-malin',
|
||||
name: 'Samedi Malin',
|
||||
type: 'if_then',
|
||||
enabled: true,
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other'
|
||||
},
|
||||
{
|
||||
id: 'default-christmas',
|
||||
name: 'Vacances de Noël',
|
||||
type: 'time_window',
|
||||
enabled: true,
|
||||
startDate: '2026-12-19T23:00:00.000Z',
|
||||
endDate: '2027-01-02T23:00:00.000Z',
|
||||
placeDays: 5,
|
||||
placeType: 'any'
|
||||
}
|
||||
] as any;
|
||||
|
||||
const vacs = [
|
||||
{ id: '1', type: 'cp', numberOfDays: 5, expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)) },
|
||||
{ id: '2', type: 'rtt', numberOfDays: 5, expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)) },
|
||||
];
|
||||
|
||||
sd.optimizeVacations(vacs, rules, cs, new Date(), new Date(new Date().setFullYear(new Date().getFullYear() + 1))).then(res => {
|
||||
console.log("Remaining:", res);
|
||||
const vacPlaced = sd._datesSelected.filter(d => ['cp', 'rtt', 'other'].includes(d.type as string));
|
||||
console.log("Placed vacs:", vacPlaced.length);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const i18nDir = path.join(__dirname, '../src/i18n');
|
||||
const registryFile = path.join(__dirname, '../src/app/i18n-registry.ts');
|
||||
|
||||
if (!fs.existsSync(i18nDir)) {
|
||||
console.error(`i18n directory not found at ${i18nDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(i18nDir).filter((file) => file.endsWith('.json'));
|
||||
|
||||
let importStatements = '';
|
||||
let registryEntries = '';
|
||||
|
||||
files.forEach((file) => {
|
||||
const langCode = path.basename(file, '.json');
|
||||
importStatements += `import ${langCode} from '../i18n/${file}';\n`;
|
||||
registryEntries += `\t${langCode}: ${langCode} as AppTranslations,\n`;
|
||||
});
|
||||
|
||||
const content = `// This file is auto-generated by scripts/generate-langs.js. Do not edit directly.
|
||||
import { AppTranslations } from './services/translation-service';
|
||||
${importStatements}
|
||||
export const translationsMap: Record<string, AppTranslations> = {
|
||||
${registryEntries}};
|
||||
|
||||
export const availableLanguagesList: string[] = Object.keys(translationsMap);
|
||||
`;
|
||||
|
||||
fs.writeFileSync(registryFile, content, 'utf8');
|
||||
console.log(`Successfully generated i18n registry at ${registryFile} with ${files.length} languages.`);
|
||||
+41
-13
@@ -9,14 +9,52 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
app-user-input {
|
||||
display: block;
|
||||
flex: 3;
|
||||
min-width: 350px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
app-calendar-year {
|
||||
display: block;
|
||||
flex: 7;
|
||||
min-width: 700px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
app-user-input,
|
||||
app-calendar-year {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
app-user-input {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mat-divider-vertical {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-divider-horizontal {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +65,6 @@
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mat-divider-vertical {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-divider-horizontal {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-divider-vertical {
|
||||
height: 80vh;
|
||||
width: 1px;
|
||||
|
||||
@@ -5,13 +5,9 @@
|
||||
@if (calendarSettingsService.get('showWeekNumbers')()) {
|
||||
<p class="day empty week-number"></p>
|
||||
}
|
||||
<p class="day">L</p>
|
||||
<p class="day">M</p>
|
||||
<p class="day">M</p>
|
||||
<p class="day">J</p>
|
||||
<p class="day">V</p>
|
||||
<p class="day">S</p>
|
||||
<p class="day">D</p>
|
||||
@for (letter of getWeekdayLetters(); track $index) {
|
||||
<p class="day">{{ letter }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="cols">
|
||||
@for (week of weeksInAMonth; track $index) {
|
||||
@@ -36,7 +32,7 @@
|
||||
[class.selected]="selectedDates.isSelected(day)"
|
||||
[class]="selectedDates.getClasses(day)"
|
||||
class="day number"
|
||||
[matTooltip]="selectedDates.getTooltip(day)"
|
||||
[matTooltip]="getDayTooltip(day)"
|
||||
[matTooltipShowDelay]="500"
|
||||
>
|
||||
{{ day.getDate() }}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import { CalendarService, Month, monthNames, SelectedDates } from '../../services/calendar-service';
|
||||
import { CalendarService, Month, SelectedDates } from '../../services/calendar-service';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { DateCacheService } from '../../services/date-cache.service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-month',
|
||||
@@ -13,6 +14,7 @@ import { DateCacheService } from '../../services/date-cache.service';
|
||||
export class CalendarMonth implements OnInit {
|
||||
protected calendarSettingsService = inject(CalendarSettingsService);
|
||||
private dateCache = inject(DateCacheService);
|
||||
protected translationService = inject(TranslationService);
|
||||
|
||||
@Input({ required: true })
|
||||
monthIndex!: number;
|
||||
@@ -20,6 +22,10 @@ export class CalendarMonth implements OnInit {
|
||||
@Input({ required: true })
|
||||
year!: number;
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
// Use DateCacheService instead of creating Date objects
|
||||
getNow(): Date {
|
||||
return this.dateCache.getNow();
|
||||
@@ -33,7 +39,24 @@ export class CalendarMonth implements OnInit {
|
||||
selectedDates!: SelectedDates;
|
||||
|
||||
getMonthName(): string {
|
||||
return monthNames[this.monthIndex];
|
||||
const locale = this.translationService.locale();
|
||||
const tempDate = new Date(this.year, this.monthIndex, 1);
|
||||
const monthName = tempDate.toLocaleString(locale, { month: 'long' });
|
||||
return monthName.charAt(0).toUpperCase() + monthName.slice(1);
|
||||
}
|
||||
|
||||
getWeekdayLetters(): string[] {
|
||||
// January 5, 2026 is a Monday
|
||||
const baseDate = new Date(2026, 0, 5);
|
||||
const locale = this.translationService.locale();
|
||||
const letters: string[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(baseDate);
|
||||
date.setDate(baseDate.getDate() + i);
|
||||
const letter = date.toLocaleString(locale, { weekday: 'narrow' });
|
||||
letters.push(letter.toUpperCase());
|
||||
}
|
||||
return letters;
|
||||
}
|
||||
|
||||
getWeekNumber(week: (Date | null)[]): number | null {
|
||||
@@ -45,6 +68,13 @@ export class CalendarMonth implements OnInit {
|
||||
return null;
|
||||
}
|
||||
|
||||
getDayTooltip(day: Date): string | null {
|
||||
const tooltips = this.selectedDates.datesSelected
|
||||
.filter((dateSelected) => dateSelected.isSelected(day) && dateSelected.type)
|
||||
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
|
||||
return tooltips.length > 0 ? tooltips.join(' + ') : null;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.weeksInAMonth = CalendarService.weeksInMonth(this.monthIndex, this.year);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<button matFab extended (click)="openDialog()">
|
||||
<mat-icon>settings</mat-icon>
|
||||
Calendar Settings
|
||||
{{ tr().settings.title }}
|
||||
</button>
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { CalendarSettingsDialog } from '../dialog/calendar-settings-dialog/calendar-settings-dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-settings',
|
||||
@@ -12,6 +13,12 @@ import { MatDividerModule } from '@angular/material/divider';
|
||||
styleUrl: './calendar-settings.scss',
|
||||
})
|
||||
export class CalendarSettings {
|
||||
private readonly translationService = inject(TranslationService);
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
constructor(private dialog: MatDialog) {}
|
||||
|
||||
openDialog() {
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
<mat-spinner [mode]="'determinate'" [value]="loadingProgress()"></mat-spinner>
|
||||
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
||||
</div>
|
||||
<p>Calcul en cours...</p>
|
||||
<p>{{ tr().loading }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (month of months; track $index) {
|
||||
@for (item of monthsAndYears(); track $index) {
|
||||
<mat-card class="card">
|
||||
<mat-card-content>
|
||||
<app-calendar-month
|
||||
[monthIndex]="month"
|
||||
[year]="getYear(month)"
|
||||
[monthIndex]="item.month"
|
||||
[year]="item.year"
|
||||
[selectedDates]="selectedDates"
|
||||
></app-calendar-month>
|
||||
</mat-card-content>
|
||||
|
||||
@@ -5,16 +5,11 @@
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
max-width: 65vw;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.calendar-year {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Component, effect, signal, inject, untracked } from '@angular/core';
|
||||
import { Component, effect, signal, inject, untracked, computed } from '@angular/core';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||
import { CalendarMonth } from '../calendar-month/calendar-month';
|
||||
import { CalendarService, SelectedDates } from '../../services/calendar-service';
|
||||
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { DateCacheService } from '../../services/date-cache.service';
|
||||
import { RuleService } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-year',
|
||||
@@ -18,9 +18,15 @@ export class CalendarYear {
|
||||
private dateCache = inject(DateCacheService);
|
||||
private userInput = inject(UserInputService);
|
||||
private calendarService = inject(CalendarService);
|
||||
private calendarSettingsService = inject(CalendarSettingsService);
|
||||
private ruleService = inject(RuleService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
protected isLoading = signal(true);
|
||||
|
||||
protected loadingProgress = signal(0);
|
||||
|
||||
// Use DateCacheService instead of creating Date objects
|
||||
@@ -36,11 +42,16 @@ export class CalendarYear {
|
||||
effect(() => {
|
||||
this.userInput.computeTriggerSignal();
|
||||
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||
const startDate = this.userInput.startDate();
|
||||
const endDate = this.userInput.endDate();
|
||||
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForPeriod(startDate, endDate);
|
||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||
const c = untracked(() => this.calendarSettingsService.get('samediMalin')());
|
||||
console.log('Samedi Malin setting:', c);
|
||||
const currentRules = untracked(() => this.ruleService.rules);
|
||||
|
||||
console.log('Optimizing vacations with data:', vacationData);
|
||||
console.log('Using rules:', currentRules);
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
// Defer execution to allow UI to update
|
||||
@@ -51,8 +62,10 @@ export class CalendarYear {
|
||||
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
|
||||
const remainingDays = await this.selectedDates.optimizeVacations(
|
||||
vacationDaysCopy,
|
||||
this.calendarSettingsService,
|
||||
currentRules,
|
||||
this.calendarService,
|
||||
startDate,
|
||||
endDate,
|
||||
(progress: number) => {
|
||||
this.loadingProgress.set(progress);
|
||||
},
|
||||
@@ -68,11 +81,22 @@ export class CalendarYear {
|
||||
|
||||
protected selectedDates: SelectedDates = new SelectedDates();
|
||||
|
||||
getYear(monthIndex: number): number {
|
||||
const offset = this.currentMonth <= monthIndex ? 0 : 1;
|
||||
return this.currentYear + offset;
|
||||
}
|
||||
protected monthsAndYears = computed(() => {
|
||||
const start = this.userInput.startDate();
|
||||
const end = this.userInput.endDate();
|
||||
|
||||
// months starting from actual date - cache this array
|
||||
protected months: number[] = Array.from({ length: 12 }, (_, i) => (this.currentMonth + i) % 12);
|
||||
const result: { month: number; year: number }[] = [];
|
||||
const current = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
const limit = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
|
||||
while (current <= limit) {
|
||||
result.push({
|
||||
month: current.getMonth(),
|
||||
year: current.getFullYear(),
|
||||
});
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -1,21 +1,37 @@
|
||||
<div class="container">
|
||||
<div>
|
||||
<h1>Calendar Settings</h1>
|
||||
<p>Configure your calendar settings here.</p>
|
||||
<div class="container" style="min-width: 280px; padding: 8px">
|
||||
<div style="margin-bottom: 1.5rem; text-align: center">
|
||||
<h1 style="margin: 0 0 0.5rem 0; font-size: 1.5rem">{{ tr().settings.title }}</h1>
|
||||
<p style="margin: 0; opacity: 0.7; font-size: 0.9rem">{{ tr().settings.description }}</p>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem">
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem; width: 100%">
|
||||
@for (setting of calendarSettingsService.settingsArray; track setting.id) {
|
||||
<div style="align-self: flex-start">
|
||||
<div style="align-self: flex-start; margin-bottom: 0.5rem">
|
||||
<mat-slide-toggle
|
||||
[id]="setting.id"
|
||||
[name]="setting.id"
|
||||
[checked]="setting.signal()"
|
||||
(change)="setting.signal.set($event.checked)"
|
||||
>
|
||||
{{ setting.description }}
|
||||
{{ getSettingDescription(setting.id) }}
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" style="width: 100%; margin-top: 0.5rem">
|
||||
<mat-label>{{ tr().settings.selectLanguage }}</mat-label>
|
||||
<mat-select
|
||||
[value]="translationService.language()"
|
||||
(valueChange)="translationService.setLanguage($event)"
|
||||
>
|
||||
@for (lang of translationService.availableLanguages; track lang) {
|
||||
<mat-option [value]="lang">{{
|
||||
translationService.getLanguageName(lang)
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-button (click)="closeDialog()">Fermer</button>
|
||||
<button mat-button (click)="closeDialog()" style="margin-top: 1.5rem; width: 100%">
|
||||
{{ tr().settings.close }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { vi } from 'vitest';
|
||||
import { CalendarSettingsDialog } from './calendar-settings-dialog';
|
||||
|
||||
describe('CalendarSettingsDialog', () => {
|
||||
@@ -15,7 +16,7 @@ describe('CalendarSettingsDialog', () => {
|
||||
{
|
||||
provide: MatDialogRef,
|
||||
useValue: {
|
||||
close: jasmine.createSpy('close'),
|
||||
close: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,16 +3,32 @@ import { MatButton } from '@angular/material/button';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { CalendarSettingsService } from '../../../services/calendar-settings-service';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TranslationService } from '../../../services/translation-service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-settings-dialog',
|
||||
imports: [MatButton, MatSlideToggle],
|
||||
imports: [MatButton, MatSlideToggle, MatFormFieldModule, MatSelectModule],
|
||||
templateUrl: './calendar-settings-dialog.html',
|
||||
styleUrl: './calendar-settings-dialog.scss',
|
||||
})
|
||||
export class CalendarSettingsDialog {
|
||||
public readonly dialogRef = inject<MatDialogRef<CalendarSettingsDialog>>(MatDialogRef);
|
||||
protected readonly calendarSettingsService = inject(CalendarSettingsService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
getSettingDescription(id: string): string {
|
||||
const tr = this.tr();
|
||||
if (id === 'showWeekNumbers') {
|
||||
return tr.settings.showWeekNumbers;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
closeDialog() {
|
||||
this.dialogRef.close();
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
<mat-expansion-panel class="rules-panel-container mat-elevation-z2">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>rule</mat-icon>
|
||||
{{ tr().rules.title }}
|
||||
</mat-panel-title>
|
||||
<mat-panel-description> {{ tr().rules.subtitle }} </mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="priority-statement">
|
||||
<mat-icon color="primary">info</mat-icon>
|
||||
<span>{{ tr().rules.priorityInfo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="rules-list" cdkDropList (cdkDropListDropped)="drop($event)">
|
||||
@for (rule of rules; track rule.id; let i = $index) {
|
||||
<div
|
||||
class="rule-item"
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="editingRuleId() === rule.id"
|
||||
[class.editing]="editingRuleId() === rule.id"
|
||||
>
|
||||
@if (editingRuleId() !== rule.id) {
|
||||
<div class="rule-drag-grip">
|
||||
<mat-icon class="drag-handle-icon">drag_handle</mat-icon>
|
||||
</div>
|
||||
<div class="rule-display">
|
||||
<div class="rule-header">
|
||||
<mat-slide-toggle
|
||||
[checked]="rule.enabled"
|
||||
(change)="toggleRule(rule.id)"
|
||||
color="primary"
|
||||
>
|
||||
<span class="rule-name"
|
||||
><span class="rule-number">{{ i + 1 }}.</span>
|
||||
{{ rule.name }}</span
|
||||
>
|
||||
</mat-slide-toggle>
|
||||
<div class="rule-actions">
|
||||
<button mat-icon-button (click)="startEdit(rule, $event)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="deleteRule(rule.id, $event)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-summary" [innerHTML]="getRuleSummary(rule)"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rule-editor">
|
||||
<h4>{{ tr().rules.editRule }}</h4>
|
||||
|
||||
<mat-form-field class="full-width">
|
||||
<mat-label>{{ tr().rules.ruleName }}</mat-label>
|
||||
<input matInput [(ngModel)]="$any(editBuffer).name" required />
|
||||
</mat-form-field>
|
||||
|
||||
@if ($any(editBuffer).type === RuleType.IF_THEN) {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.holidayFalls }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).dayOfWeek">
|
||||
@for (day of daysOfWeek; track day.value) {
|
||||
<mat-option [value]="day.value">{{
|
||||
day.label
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="editor-row">
|
||||
<span class="action-text">{{ tr().rules.place }}</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>{{ tr().rules.days }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).placeDays"
|
||||
min="1"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.type }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).placeType">
|
||||
@for (type of placeDayTypes; track type) {
|
||||
<mat-option [value]="type">{{
|
||||
getDayTypeLabel(type)
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.position }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).placement">
|
||||
@for (opt of placementOptions; track opt.value) {
|
||||
<mat-option [value]="opt.value">{{
|
||||
opt.label
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="editor-row">
|
||||
<span class="action-text">{{ tr().rules.getBonus }}</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>{{ tr().rules.days }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).rewardDays"
|
||||
min="0"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.type }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).rewardType">
|
||||
@for (type of rewardDayTypes; track type) {
|
||||
<mat-option [value]="type">{{
|
||||
getDayTypeLabel(type)
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.startDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="startPicker"
|
||||
[(ngModel)]="$any(editBuffer).startDate"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="startPicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #startPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.endDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="endPicker"
|
||||
[(ngModel)]="$any(editBuffer).endDate"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="endPicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #endPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="editor-row">
|
||||
<span class="action-text">{{ tr().rules.place }}</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>{{ tr().rules.days }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).placeDays"
|
||||
min="1"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.type }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).placeType">
|
||||
@for (type of placeDayTypes; track type) {
|
||||
<mat-option [value]="type">{{
|
||||
getDayTypeLabel(type)
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="editor-actions">
|
||||
<button mat-button (click)="cancelEdit()">
|
||||
{{ tr().rules.cancel }}
|
||||
</button>
|
||||
<button mat-flat-button color="primary" (click)="saveEdit()">
|
||||
{{ tr().rules.save }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isAddingNew()) {
|
||||
<div class="rule-item editing">
|
||||
<div class="rule-editor">
|
||||
<h4>{{ tr().rules.addRule }}</h4>
|
||||
|
||||
<mat-form-field class="full-width">
|
||||
<mat-label>{{ tr().rules.ruleName }}</mat-label>
|
||||
<input matInput [(ngModel)]="$any(editBuffer).name" required />
|
||||
</mat-form-field>
|
||||
|
||||
@if (newRuleType() === RuleType.IF_THEN) {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.holidayFalls }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).dayOfWeek">
|
||||
@for (day of daysOfWeek; track day.value) {
|
||||
<mat-option [value]="day.value">{{ day.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="editor-row">
|
||||
<span class="action-text">{{ tr().rules.place }}</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>{{ tr().rules.days }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).placeDays"
|
||||
min="1"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.type }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).placeType">
|
||||
@for (type of placeDayTypes; track type) {
|
||||
<mat-option [value]="type">{{
|
||||
getDayTypeLabel(type)
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.position }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).placement">
|
||||
@for (opt of placementOptions; track opt.value) {
|
||||
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="editor-row">
|
||||
<span class="action-text">{{ tr().rules.getBonus }}</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>{{ tr().rules.days }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).rewardDays"
|
||||
min="0"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.type }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).rewardType">
|
||||
@for (type of rewardDayTypes; track type) {
|
||||
<mat-option [value]="type">{{
|
||||
getDayTypeLabel(type)
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.startDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="startPicker"
|
||||
[(ngModel)]="$any(editBuffer).startDate"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="startPicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #startPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.endDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="endPicker"
|
||||
[(ngModel)]="$any(editBuffer).endDate"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="endPicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #endPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="editor-row">
|
||||
<span class="action-text">{{ tr().rules.place }}</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>{{ tr().rules.days }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).placeDays"
|
||||
min="1"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.type }}</mat-label>
|
||||
<mat-select [(ngModel)]="$any(editBuffer).placeType">
|
||||
@for (type of placeDayTypes; track type) {
|
||||
<mat-option [value]="type">{{
|
||||
getDayTypeLabel(type)
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="editor-actions">
|
||||
<button mat-button (click)="cancelEdit()">{{ tr().rules.cancel }}</button>
|
||||
<button mat-flat-button color="primary" (click)="saveEdit()">
|
||||
{{ tr().rules.add }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="add-buttons" [class.hidden]="isAddingNew() || editingRuleId()">
|
||||
<button mat-stroked-button (click)="startAddNew(RuleType.IF_THEN)">
|
||||
<mat-icon>add</mat-icon> {{ tr().rules.newIfThenRule }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="startAddNew(RuleType.TIME_WINDOW)">
|
||||
<mat-icon>add</mat-icon> {{ tr().rules.newTimeWindowRule }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
@@ -0,0 +1,218 @@
|
||||
.rules-panel-container {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
|
||||
mat-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@container (max-width: 480px) {
|
||||
mat-panel-description {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--mat-sys-surface-container);
|
||||
|
||||
&:not(.editing) {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.editing) {
|
||||
border-color: var(--mat-sys-outline);
|
||||
}
|
||||
|
||||
&.editing {
|
||||
border: 1px solid var(--mat-sys-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 5px 5px -3px rgba(0, 0, 0, 0.2),
|
||||
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.rules-list.cdk-drop-list-dragging .rule-item:not(.cdk-drag-placeholder) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.rule-display {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.priority-statement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-drag-grip {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
margin-bottom: -8px;
|
||||
color: var(--mat-sys-outline);
|
||||
opacity: 0.6;
|
||||
cursor: grab;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.rule-item:active & {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.rule-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.rule-number {
|
||||
color: var(--mat-sys-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.rule-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rule-summary {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
padding-left: 48px; /* Align with toggle switch text */
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-editor {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
border-radius: 8px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.action-text {
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.small-input {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { RulesPanel } from './rules-panel';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { RuleService, RuleType } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
|
||||
describe('RulesPanel', () => {
|
||||
let component: RulesPanel;
|
||||
let fixture: ComponentFixture<RulesPanel>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear localStorage before testing
|
||||
localStorage.clear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RulesPanel, BrowserAnimationsModule],
|
||||
providers: [provideZonelessChangeDetection(), RuleService],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RulesPanel);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// Force language to 'fr' so the default rule names match French assertions
|
||||
const translationService = TestBed.inject(TranslationService);
|
||||
translationService.setLanguage('fr');
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display initial rules from service', () => {
|
||||
expect(component.rules.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should start adding new If-Then rule', () => {
|
||||
component.startAddNew(RuleType.IF_THEN);
|
||||
|
||||
expect(component['isAddingNew']()).toBe(true);
|
||||
expect(component['newRuleType']()).toBe(RuleType.IF_THEN);
|
||||
expect(component['editBuffer'].type).toBe(RuleType.IF_THEN);
|
||||
expect(component['editBuffer'].name).toBe('Nouvelle Règle');
|
||||
});
|
||||
|
||||
it('should start adding new Time Window rule', () => {
|
||||
component.startAddNew(RuleType.TIME_WINDOW);
|
||||
|
||||
expect(component['isAddingNew']()).toBe(true);
|
||||
expect(component['newRuleType']()).toBe(RuleType.TIME_WINDOW);
|
||||
expect(component['editBuffer'].type).toBe(RuleType.TIME_WINDOW);
|
||||
expect(component['editBuffer'].name).toBe('Nouvelle Période');
|
||||
});
|
||||
|
||||
it('should cancel edit', () => {
|
||||
component.startAddNew(RuleType.IF_THEN);
|
||||
component.cancelEdit();
|
||||
|
||||
expect(component['isAddingNew']()).toBe(false);
|
||||
expect(component['editingRuleId']()).toBeNull();
|
||||
expect(Object.keys(component['editBuffer']).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should save a new rule', () => {
|
||||
const initialCount = component.rules.length;
|
||||
|
||||
component.startAddNew(RuleType.IF_THEN);
|
||||
component['editBuffer'].name = 'Custom Test Rule';
|
||||
component.saveEdit();
|
||||
|
||||
expect(component.rules.length).toBe(initialCount + 1);
|
||||
expect(component.rules.some((r) => r.name === 'Custom Test Rule')).toBe(true);
|
||||
});
|
||||
|
||||
it('should update an existing rule', () => {
|
||||
const targetRule = component.rules[0];
|
||||
|
||||
// Create a mock event with stopPropagation
|
||||
const mockEvent = { stopPropagation: () => {} } as Event;
|
||||
|
||||
component.startEdit(targetRule, mockEvent);
|
||||
component['editBuffer'].name = 'Updated Name';
|
||||
component.saveEdit();
|
||||
|
||||
expect(component.rules.find((r) => r.id === targetRule.id)?.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should delete a rule', () => {
|
||||
const targetId = component.rules[0].id;
|
||||
const initialCount = component.rules.length;
|
||||
|
||||
const mockEvent = { stopPropagation: () => {} } as Event;
|
||||
component.deleteRule(targetId, mockEvent);
|
||||
|
||||
expect(component.rules.length).toBe(initialCount - 1);
|
||||
expect(component.rules.some((r) => r.id === targetId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle a rule', () => {
|
||||
const targetRule = component.rules[0];
|
||||
const initialState = targetRule.enabled;
|
||||
|
||||
component.toggleRule(targetRule.id);
|
||||
|
||||
expect(component.rules.find((r) => r.id === targetRule.id)?.enabled).toBe(!initialState);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
inject,
|
||||
signal,
|
||||
Injectable,
|
||||
effect,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
RuleService,
|
||||
RuleType,
|
||||
IfThenRule,
|
||||
TimeWindowRule,
|
||||
VacationRule,
|
||||
PlacementStrategy,
|
||||
} from '../../services/rule-service';
|
||||
import { SelectableDayType } from '../../services/calendar-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import {
|
||||
DateAdapter,
|
||||
MAT_DATE_FORMATS,
|
||||
MAT_DATE_LOCALE,
|
||||
NativeDateAdapter,
|
||||
} from '@angular/material/core';
|
||||
import { CdkDragDrop, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
|
||||
|
||||
@Injectable()
|
||||
class CustomDateAdapter extends NativeDateAdapter {
|
||||
override format(date: Date, displayFormat: unknown): string {
|
||||
if (displayFormat === 'input') {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
return super.format(date, displayFormat as object);
|
||||
}
|
||||
}
|
||||
|
||||
const MY_DATE_FORMATS = {
|
||||
parse: { dateInput: 'input' },
|
||||
display: {
|
||||
dateInput: 'input',
|
||||
monthYearLabel: { year: 'numeric', month: 'short' },
|
||||
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||
monthYearA11yLabel: { year: 'numeric', month: 'long' },
|
||||
},
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-rules-panel',
|
||||
imports: [
|
||||
MatExpansionModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatSlideToggleModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
FormsModule,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
],
|
||||
templateUrl: './rules-panel.html',
|
||||
styleUrl: './rules-panel.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{ provide: DateAdapter, useClass: CustomDateAdapter },
|
||||
{ provide: MAT_DATE_LOCALE, useValue: 'en-GB' },
|
||||
{ provide: MAT_DATE_FORMATS, useValue: MY_DATE_FORMATS },
|
||||
],
|
||||
})
|
||||
export class RulesPanel {
|
||||
public ruleService = inject(RuleService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
private readonly dateAdapter = inject(DateAdapter);
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.dateAdapter.setLocale(this.translationService.locale());
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly RuleType = RuleType;
|
||||
protected readonly placeDayTypes = Object.values(SelectableDayType);
|
||||
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
|
||||
(t) => t !== SelectableDayType.ANY,
|
||||
);
|
||||
|
||||
protected get daysOfWeek() {
|
||||
const tr = this.tr();
|
||||
return [
|
||||
{ value: 1, label: tr.rules.daysOfWeek[1] },
|
||||
{ value: 2, label: tr.rules.daysOfWeek[2] },
|
||||
{ value: 3, label: tr.rules.daysOfWeek[3] },
|
||||
{ value: 4, label: tr.rules.daysOfWeek[4] },
|
||||
{ value: 5, label: tr.rules.daysOfWeek[5] },
|
||||
{ value: 6, label: tr.rules.daysOfWeek[6] },
|
||||
{ value: 0, label: tr.rules.daysOfWeek[0] },
|
||||
];
|
||||
}
|
||||
|
||||
protected get placementOptions(): { value: PlacementStrategy; label: string }[] {
|
||||
const tr = this.tr();
|
||||
return [
|
||||
{ value: 'before', label: tr.rules.placement.before },
|
||||
{ value: 'after', label: tr.rules.placement.after },
|
||||
{ value: 'around', label: tr.rules.placement.around },
|
||||
{ value: 'auto', label: tr.rules.placement.auto },
|
||||
];
|
||||
}
|
||||
|
||||
protected editingRuleId = signal<string | null>(null);
|
||||
protected isAddingNew = signal(false);
|
||||
|
||||
protected newRuleType = signal<RuleType>(RuleType.IF_THEN);
|
||||
|
||||
protected editBuffer: Partial<VacationRule> = {};
|
||||
|
||||
public get rules() {
|
||||
return this.ruleService.rules;
|
||||
}
|
||||
|
||||
public drop(event: CdkDragDrop<VacationRule[]>) {
|
||||
if (event.previousIndex !== event.currentIndex) {
|
||||
this.ruleService.reorderRules(event.previousIndex, event.currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public toggleRule(id: string) {
|
||||
this.ruleService.toggleRule(id);
|
||||
}
|
||||
|
||||
public startEdit(rule: VacationRule, event: Event) {
|
||||
event.stopPropagation();
|
||||
this.editingRuleId.set(rule.id);
|
||||
this.isAddingNew.set(false);
|
||||
this.editBuffer = { ...rule };
|
||||
}
|
||||
|
||||
public cancelEdit() {
|
||||
this.editingRuleId.set(null);
|
||||
this.isAddingNew.set(false);
|
||||
this.editBuffer = {};
|
||||
}
|
||||
|
||||
public saveEdit() {
|
||||
if (this.editBuffer.id) {
|
||||
this.ruleService.updateRule(this.editBuffer as VacationRule);
|
||||
} else {
|
||||
this.ruleService.addRule({
|
||||
...this.editBuffer,
|
||||
id: crypto.randomUUID(),
|
||||
enabled: true,
|
||||
} as VacationRule);
|
||||
}
|
||||
this.cancelEdit();
|
||||
}
|
||||
|
||||
public deleteRule(id: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
this.ruleService.deleteRule(id);
|
||||
}
|
||||
|
||||
public startAddNew(type: RuleType) {
|
||||
this.isAddingNew.set(true);
|
||||
this.editingRuleId.set(null);
|
||||
this.newRuleType.set(type);
|
||||
|
||||
if (type === RuleType.IF_THEN) {
|
||||
this.editBuffer = {
|
||||
name: this.tr().rules.defaultNewRuleName,
|
||||
type: RuleType.IF_THEN,
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: SelectableDayType.CP,
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
} as Partial<IfThenRule>;
|
||||
} else {
|
||||
this.editBuffer = {
|
||||
name: this.tr().rules.defaultNewPeriodName,
|
||||
type: RuleType.TIME_WINDOW,
|
||||
startDate: new Date(),
|
||||
endDate: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
|
||||
placeDays: 5,
|
||||
placeType: SelectableDayType.CP,
|
||||
} as Partial<TimeWindowRule>;
|
||||
}
|
||||
}
|
||||
|
||||
protected getDayTypeLabel(type: string | undefined | null): string {
|
||||
if (!type) return '';
|
||||
return this.translationService.getDayTypeLabel(type);
|
||||
}
|
||||
|
||||
public getRuleSummary(rule: VacationRule): string {
|
||||
const tr = this.tr();
|
||||
if (rule.type === RuleType.IF_THEN) {
|
||||
const ifThen = rule as IfThenRule;
|
||||
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
|
||||
const placeTypeLabel = this.getDayTypeLabel(ifThen.placeType);
|
||||
const placementLabel = (
|
||||
this.placementOptions.find((p) => p.value === ifThen.placement)?.label ?? ''
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const rewardTypeLabel = this.getDayTypeLabel(ifThen.rewardType);
|
||||
|
||||
return tr.rules.ifThenTemplate
|
||||
.replace('{day}', dayLabel)
|
||||
.replace('{placeDays}', String(ifThen.placeDays))
|
||||
.replace('{placeType}', placeTypeLabel)
|
||||
.replace('{placement}', placementLabel)
|
||||
.replace('{rewardDays}', String(ifThen.rewardDays))
|
||||
.replace('{rewardType}', rewardTypeLabel);
|
||||
} else {
|
||||
const timeWindow = rule as TimeWindowRule;
|
||||
const placeTypeLabel = this.getDayTypeLabel(timeWindow.placeType);
|
||||
const startDateStr = new Date(timeWindow.startDate).toLocaleDateString(
|
||||
this.translationService.locale(),
|
||||
);
|
||||
const endDateStr = new Date(timeWindow.endDate).toLocaleDateString(
|
||||
this.translationService.locale(),
|
||||
);
|
||||
|
||||
return tr.rules.timeWindowTemplate
|
||||
.replace('{placeDays}', String(timeWindow.placeDays))
|
||||
.replace('{placeType}', placeTypeLabel)
|
||||
.replace('{startDate}', startDateStr)
|
||||
.replace('{endDate}', endDateStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,31 @@
|
||||
<div class="user-input">
|
||||
<p>Optimisez vos jours de vacances en fonction des jours fériés et des week-ends !</p>
|
||||
<p>{{ tr().app.description }}</p>
|
||||
<mat-card class="period-selection-card mat-elevation-z2">
|
||||
<mat-card-content>
|
||||
<h3 class="period-title">
|
||||
<mat-icon>date_range</mat-icon> {{ tr().userInput.choosePeriod }}
|
||||
</h3>
|
||||
<mat-form-field appearance="outline" class="period-field">
|
||||
<mat-label>{{ tr().userInput.choosePeriod }}</mat-label>
|
||||
<mat-date-range-input [rangePicker]="picker">
|
||||
<input
|
||||
matStartDate
|
||||
[value]="startDate()"
|
||||
(dateChange)="onStartDateChange($event.value)"
|
||||
[placeholder]="tr().userInput.startDate"
|
||||
/>
|
||||
<input
|
||||
matEndDate
|
||||
[value]="endDate()"
|
||||
(dateChange)="onEndDateChange($event.value)"
|
||||
[placeholder]="tr().userInput.endDate"
|
||||
/>
|
||||
</mat-date-range-input>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-date-range-picker #picker></mat-date-range-picker>
|
||||
</mat-form-field>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="listOfDays">
|
||||
@for (day of daysOff; track day.id) {
|
||||
<div class="day-input-wrapper" animate.enter="day-enter" animate.leave="day-leave">
|
||||
@@ -11,32 +37,40 @@
|
||||
</div>
|
||||
}
|
||||
<mat-card class="add-day-card mat-elevation-z2" (click)="addDay()">
|
||||
<button matFab aria-label="Add entry">
|
||||
<button matFab [aria-label]="tr().userInput.addVacationDay">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
<h3>Ajouter un jour de vacances</h3>
|
||||
<h3>{{ tr().userInput.addVacationDay }}</h3>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button matFab extended (click)="triggerComputation()">
|
||||
<mat-icon>calculate</mat-icon>
|
||||
Calculer
|
||||
{{ tr().userInput.calculate }}
|
||||
</button>
|
||||
<app-calendar-settings></app-calendar-settings>
|
||||
</div>
|
||||
<app-rules-panel></app-rules-panel>
|
||||
@if (hasRemainingDays()) {
|
||||
<mat-card class="mat-elevation-z2 warning-card">
|
||||
<mat-card-content class="warning-content">
|
||||
<mat-icon color="warn">warning</mat-icon>
|
||||
<div class="warning">
|
||||
<span>
|
||||
{{ remainingDays() }} jour{{ remainingDays() > 1 ? 's' : '' }} de vacances
|
||||
non placé{{ remainingDays() > 1 ? 's' : '' }}:
|
||||
{{
|
||||
tr()
|
||||
.userInput.remainingDaysWarning.replace(
|
||||
'{count}',
|
||||
remainingDays().toString()
|
||||
)
|
||||
.split('{plural}')
|
||||
.join(remainingDays() > 1 ? 's' : '')
|
||||
}}
|
||||
</span>
|
||||
@for (day of remainingDaysDetails(); track $index) {
|
||||
<span class="remaining-day-detail">- {{ day }}</span>
|
||||
}
|
||||
<span> Veuillez les placer manuellement. </span>
|
||||
<span> {{ tr().userInput.remainingDaysManualPlace }} </span>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -23,6 +23,22 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.period-selection-card {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.period-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.period-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listOfDays {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { UserInput } from './user-input';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||
|
||||
describe('UserInput', () => {
|
||||
let component: UserInput;
|
||||
@@ -12,7 +13,11 @@ describe('UserInput', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UserInput],
|
||||
providers: [provideZonelessChangeDetection(), provideAnimationsAsync()],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideAnimationsAsync(),
|
||||
provideNativeDateAdapter(),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UserInput);
|
||||
@@ -43,4 +48,24 @@ describe('UserInput', () => {
|
||||
component.removeFromList(0);
|
||||
expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1);
|
||||
});
|
||||
|
||||
it('should trigger computation', () => {
|
||||
const initialValue = userInputService.computeTriggerSignal();
|
||||
component.triggerComputation();
|
||||
expect(userInputService.computeTriggerSignal()).toBe(initialValue + 1);
|
||||
});
|
||||
|
||||
it('should update start date', () => {
|
||||
const newDate = new Date();
|
||||
newDate.setFullYear(newDate.getFullYear() + 2);
|
||||
component.onStartDateChange(newDate);
|
||||
expect(userInputService.startDate().getFullYear()).toBe(newDate.getFullYear());
|
||||
});
|
||||
|
||||
it('should update end date', () => {
|
||||
const newDate = new Date();
|
||||
newDate.setFullYear(newDate.getFullYear() + 3);
|
||||
component.onEndDateChange(newDate);
|
||||
expect(userInputService.endDate().getFullYear()).toBe(newDate.getFullYear());
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,25 @@
|
||||
import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||
import { RulesPanel } from '../rules-panel/rules-panel';
|
||||
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { DayInput } from '../userInput/day-input/day-input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { tooltipTypeMapping, DayType, VacDay } from '../../services/calendar-service';
|
||||
import { VacDay } from '../../services/calendar-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-input',
|
||||
imports: [
|
||||
MatInputModule,
|
||||
MatDatepickerModule,
|
||||
MatFormFieldModule,
|
||||
CalendarSettings,
|
||||
RulesPanel,
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
MatIcon,
|
||||
@@ -25,6 +32,11 @@ import { tooltipTypeMapping, DayType, VacDay } from '../../services/calendar-ser
|
||||
})
|
||||
export class UserInput {
|
||||
private userInputService: UserInputService = inject(UserInputService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
protected remainingDays = computed(() => {
|
||||
const remaining = this.userInputService.remainingVacationDaysSignal();
|
||||
@@ -36,13 +48,17 @@ export class UserInput {
|
||||
return remaining
|
||||
.filter((day) => day.numberOfDays > 0)
|
||||
.map((day) => {
|
||||
const typeName = tooltipTypeMapping[day.type as unknown as DayType];
|
||||
const expiryDate = day.expiryDate.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
return `${day.numberOfDays} ${typeName} (exp. ${expiryDate})`;
|
||||
const typeName = this.translationService.getDayTypeLabel(day.type);
|
||||
const expiryDate = day.expiryDate.toLocaleDateString(
|
||||
this.translationService.locale(),
|
||||
{
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
},
|
||||
);
|
||||
const expiresLabel = this.tr().userInput.expires;
|
||||
return `${day.numberOfDays} ${typeName} (${expiresLabel} ${expiryDate})`;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,4 +90,20 @@ export class UserInput {
|
||||
public triggerComputation() {
|
||||
this.userInputService.requestComputation();
|
||||
}
|
||||
|
||||
protected get startDate() {
|
||||
return this.userInputService.startDate;
|
||||
}
|
||||
|
||||
protected get endDate() {
|
||||
return this.userInputService.endDate;
|
||||
}
|
||||
|
||||
public onStartDateChange(date: Date | null) {
|
||||
this.userInputService.setStartDate(date);
|
||||
}
|
||||
|
||||
public onEndDateChange(date: Date | null) {
|
||||
this.userInputService.setEndDate(date);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,14 @@
|
||||
<mat-select (valueChange)="onTypeChange($event)" [value]="day().type">
|
||||
<mat-option></mat-option>
|
||||
@for (dayType of selectableDayTypes; track $index) {
|
||||
<mat-option [value]="dayType">{{ tooltipTypeMapping[dayType] }}</mat-option>
|
||||
<mat-option [value]="dayType">{{
|
||||
translationService.getDayTypeLabel(dayType)
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="dateInput">
|
||||
<mat-label>Choose a date</mat-label>
|
||||
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="datetime"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
:host {
|
||||
display: block;
|
||||
container-type: inline-size;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -12,9 +18,55 @@
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
width: 80px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (max-width: 480px) {
|
||||
.card {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
width: 70px;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.dayTypeInput {
|
||||
flex-grow: 1;
|
||||
width: 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.dateInput {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
flex-basis: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
button[matFab] {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
line-height: 40px;
|
||||
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { MatDatepickerInputEvent } from '@angular/material/datepicker';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { DayInput } from './day-input';
|
||||
import { SelectableDayType, VacDay } from '../../../services/calendar-service';
|
||||
@@ -30,4 +32,23 @@ describe('DayInput', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit dayChange on input', () => {
|
||||
const spy = vi.spyOn(component.dayChange, 'emit');
|
||||
component.onInput({ target: { value: '3' } } as unknown as Event);
|
||||
expect(spy).toHaveBeenCalledWith({ ...mockVacDay, numberOfDays: 3 });
|
||||
});
|
||||
|
||||
it('should emit dayChange on type change', () => {
|
||||
const spy = vi.spyOn(component.dayChange, 'emit');
|
||||
component.onTypeChange(SelectableDayType.RTT);
|
||||
expect(spy).toHaveBeenCalledWith({ ...mockVacDay, type: SelectableDayType.RTT });
|
||||
});
|
||||
|
||||
it('should emit dayChange on date input', () => {
|
||||
const spy = vi.spyOn(component.dayChange, 'emit');
|
||||
const newDate = new Date('2026-01-01');
|
||||
component.addEvent({ value: newDate } as unknown as MatDatepickerInputEvent<Date>);
|
||||
expect(spy).toHaveBeenCalledWith({ ...mockVacDay, expiryDate: newDate });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { SelectableDayType, tooltipTypeMapping, VacDay } from '../../../services/calendar-service';
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ChangeDetectionStrategy,
|
||||
Injectable,
|
||||
inject,
|
||||
effect,
|
||||
} from '@angular/core';
|
||||
import { SelectableDayType, VacDay } from '../../../services/calendar-service';
|
||||
import { TranslationService } from '../../../services/translation-service';
|
||||
import { MatCard } from '@angular/material/card';
|
||||
import { MatOption, MatSelect } from '@angular/material/select';
|
||||
import { MatInput, MatInputModule, MatLabel } from '@angular/material/input';
|
||||
@@ -18,6 +27,7 @@ import {
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
@Injectable()
|
||||
class CustomDateAdapter extends NativeDateAdapter {
|
||||
override format(date: Date, displayFormat: unknown): string {
|
||||
if (displayFormat === 'input') {
|
||||
@@ -85,8 +95,22 @@ export class DayInput {
|
||||
public dayChange = output<VacDay>();
|
||||
public deleteEntry = output<void>();
|
||||
|
||||
protected readonly selectableDayTypes = Object.values(SelectableDayType);
|
||||
protected readonly tooltipTypeMapping = tooltipTypeMapping;
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
private readonly dateAdapter = inject(DateAdapter);
|
||||
|
||||
get tr() {
|
||||
return this.translationService.tr;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.dateAdapter.setLocale(this.translationService.locale());
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly selectableDayTypes = Object.values(SelectableDayType).filter(
|
||||
(t) => t !== SelectableDayType.ANY,
|
||||
);
|
||||
|
||||
onInput(event: Event) {
|
||||
const value = Number((event.target as HTMLInputElement).value);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service';
|
||||
import {
|
||||
CalendarService,
|
||||
DayType,
|
||||
SelectedDate,
|
||||
SelectedDates,
|
||||
SelectableDayType,
|
||||
VacDay,
|
||||
} from './calendar-service';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
import { IfThenRule, TimeWindowRule, RuleType } from './rule-service';
|
||||
|
||||
describe('CalendarService', () => {
|
||||
let service: CalendarService;
|
||||
@@ -17,18 +26,15 @@ describe('CalendarService', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getHolidays', () => {
|
||||
describe('getHolidaysForPeriod', () => {
|
||||
it('should return an array of holidays', () => {
|
||||
const holidays = service.getHolidays();
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
const holidays = service.getHolidaysForPeriod(startDate, endDate);
|
||||
expect(Array.isArray(holidays)).toBe(true);
|
||||
expect(holidays.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should cache holidays for the same year', () => {
|
||||
const holidays1 = service.getHolidays();
|
||||
const holidays2 = service.getHolidays();
|
||||
expect(holidays1).toEqual(holidays2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static date calculation methods', () => {
|
||||
@@ -48,9 +54,12 @@ describe('CalendarService', () => {
|
||||
expect(months.length).toBe(12);
|
||||
});
|
||||
|
||||
it('should calculate months from now', () => {
|
||||
const months = service.monthsInAYearFromNow();
|
||||
expect(months.length).toBe(12);
|
||||
it('should calculate months in period', () => {
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
const months = service.monthsInPeriod(startDate, endDate);
|
||||
expect(months.length).toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('should return week number for a date', () => {
|
||||
@@ -61,15 +70,21 @@ describe('CalendarService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSelectedDatesForYear', () => {
|
||||
it('should return selected dates for the year', () => {
|
||||
const selectedDates = service.getSelectedDatesForYear();
|
||||
describe('getSelectedDatesForPeriod', () => {
|
||||
it('should return selected dates for the period', () => {
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
|
||||
expect(selectedDates).toBeDefined();
|
||||
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include weekends', () => {
|
||||
const selectedDates = service.getSelectedDatesForYear();
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
|
||||
const hasWeekend = selectedDates._datesSelected.some(
|
||||
(date) => date.type === DayType.WEEKEND,
|
||||
);
|
||||
@@ -77,7 +92,10 @@ describe('CalendarService', () => {
|
||||
});
|
||||
|
||||
it('should include holidays', () => {
|
||||
const selectedDates = service.getSelectedDatesForYear();
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
|
||||
const hasHoliday = selectedDates._datesSelected.some(
|
||||
(date) => date.type === DayType.CLOSED_DAY,
|
||||
);
|
||||
@@ -85,7 +103,10 @@ describe('CalendarService', () => {
|
||||
});
|
||||
|
||||
it('should include today', () => {
|
||||
const selectedDates = service.getSelectedDatesForYear();
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
const selectedDates = service.getSelectedDatesForPeriod(startDate, endDate);
|
||||
const hasToday = selectedDates._datesSelected.some(
|
||||
(date) => date.type === DayType.TODAY,
|
||||
);
|
||||
@@ -202,4 +223,285 @@ describe('SelectedDates', () => {
|
||||
const tooltip = selectedDates.getTooltip(date);
|
||||
expect(tooltip).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('optimizeVacations and rules engine', () => {
|
||||
let calendarService: CalendarService;
|
||||
let selectedDates: SelectedDates;
|
||||
|
||||
beforeEach(() => {
|
||||
calendarService = TestBed.inject(CalendarService);
|
||||
selectedDates = new SelectedDates();
|
||||
});
|
||||
|
||||
it('should execute rules sequentially in order', async () => {
|
||||
const vacationDays: VacDay[] = [];
|
||||
const rules: (TimeWindowRule | IfThenRule)[] = [
|
||||
{ id: '1', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
|
||||
{ id: '2', type: RuleType.TIME_WINDOW, enabled: true } as TimeWindowRule,
|
||||
{ id: '3', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
|
||||
];
|
||||
|
||||
const applyTimeWindowRulesSpy = vi.spyOn(selectedDates, 'applyTimeWindowRules');
|
||||
const applyIfThenRulesSpy = vi.spyOn(selectedDates, 'applyIfThenRules');
|
||||
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
await selectedDates.optimizeVacations(
|
||||
vacationDays,
|
||||
rules,
|
||||
calendarService,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
expect(applyIfThenRulesSpy.mock.calls.length).toBe(2);
|
||||
expect(applyTimeWindowRulesSpy.mock.calls.length).toBe(1);
|
||||
|
||||
// First rule is IF_THEN
|
||||
expect(applyIfThenRulesSpy.mock.calls[0][1][0].id).toBe('1');
|
||||
// Second rule is TIME_WINDOW
|
||||
expect(applyTimeWindowRulesSpy.mock.calls[0][1][0].id).toBe('2');
|
||||
// Third rule is IF_THEN
|
||||
expect(applyIfThenRulesSpy.mock.calls[1][1][0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('should apply time window rules', () => {
|
||||
const futureYear = new Date().getFullYear() + 2;
|
||||
const vacationDays: VacDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 5,
|
||||
expiryDate: new Date(futureYear + 1, 11, 31),
|
||||
},
|
||||
];
|
||||
const rules: TimeWindowRule[] = [
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Time Window',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
enabled: true,
|
||||
startDate: new Date(futureYear, 11, 20),
|
||||
endDate: new Date(futureYear, 11, 25),
|
||||
placeDays: 3,
|
||||
placeType: SelectableDayType.CP,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock monthsInPeriod to return a controlled range of days
|
||||
vi.spyOn(calendarService, 'monthsInPeriod').mockReturnValue([
|
||||
[
|
||||
[
|
||||
new Date(futureYear, 11, 20),
|
||||
new Date(futureYear, 11, 21),
|
||||
new Date(futureYear, 11, 22),
|
||||
new Date(futureYear, 11, 23),
|
||||
new Date(futureYear, 11, 24),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const startDate = new Date(futureYear, 11, 20);
|
||||
const endDate = new Date(futureYear, 11, 25);
|
||||
selectedDates.applyTimeWindowRules(
|
||||
vacationDays,
|
||||
rules,
|
||||
calendarService,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
// Should have placed 3 days
|
||||
const placedCount = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.CP,
|
||||
).length;
|
||||
expect(placedCount).toBe(3);
|
||||
// Should have consumed 3 days from the pool
|
||||
expect(vacationDays[0].numberOfDays).toBe(2);
|
||||
});
|
||||
|
||||
it('should apply if-then rules', () => {
|
||||
const futureYear = new Date().getFullYear() + 2;
|
||||
const vacationDays: VacDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 5,
|
||||
expiryDate: new Date(futureYear + 1, 11, 31),
|
||||
},
|
||||
];
|
||||
const rules: IfThenRule[] = [
|
||||
{
|
||||
id: 'i1',
|
||||
name: 'Samedi Malin',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 6, // Saturday
|
||||
placeDays: 2,
|
||||
placeType: SelectableDayType.CP,
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
},
|
||||
];
|
||||
|
||||
// Setup a mock holiday on a Saturday
|
||||
// Use a date far in the future so it doesn't fail the "past date" check
|
||||
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5); // Dec 5 is usually Saturday or close, let's just make sure we get a Saturday
|
||||
// To guarantee it's a Saturday for the test:
|
||||
while (saturdayHoliday.getDay() !== 6) {
|
||||
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
|
||||
}
|
||||
selectedDates._datesSelected.push(
|
||||
new SelectedDate(
|
||||
DayType.CLOSED_DAY,
|
||||
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
|
||||
),
|
||||
);
|
||||
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 3);
|
||||
selectedDates.applyIfThenRules(vacationDays, rules, startDate, endDate);
|
||||
|
||||
// Should have placed 2 CP days (e.g. Thu/Fri, Mon/Tue, or Fri/Mon)
|
||||
const placedCP = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.CP,
|
||||
).length;
|
||||
expect(placedCP).toBe(2);
|
||||
|
||||
// Should have consumed 2 CP days
|
||||
expect(vacationDays[0].numberOfDays).toBe(3);
|
||||
|
||||
// Should have added 1 OTHER reward day
|
||||
const rewardDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER);
|
||||
expect(rewardDay).toBeDefined();
|
||||
expect(rewardDay?.numberOfDays).toBe(1);
|
||||
});
|
||||
|
||||
it('should apply time window rules with ANY type', () => {
|
||||
const futureYear = new Date().getFullYear() + 2;
|
||||
const vacationDays: VacDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 2,
|
||||
expiryDate: new Date(futureYear, 11, 31),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: SelectableDayType.RTT,
|
||||
numberOfDays: 2,
|
||||
expiryDate: new Date(futureYear - 1, 11, 31), // Expires earlier
|
||||
},
|
||||
];
|
||||
const rules: TimeWindowRule[] = [
|
||||
{
|
||||
id: 'tw1',
|
||||
name: 'Vacances',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
enabled: true,
|
||||
startDate: new Date(futureYear, 6, 1),
|
||||
endDate: new Date(futureYear, 6, 15),
|
||||
placeDays: 3,
|
||||
placeType: SelectableDayType.ANY,
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(calendarService, 'monthsInPeriod').mockReturnValue([
|
||||
[
|
||||
[
|
||||
new Date(futureYear, 6, 1),
|
||||
new Date(futureYear, 6, 2),
|
||||
new Date(futureYear, 6, 3),
|
||||
new Date(futureYear, 6, 4),
|
||||
new Date(futureYear, 6, 5),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const startDate = new Date(futureYear, 6, 1);
|
||||
const endDate = new Date(futureYear, 6, 15);
|
||||
selectedDates.applyTimeWindowRules(
|
||||
vacationDays,
|
||||
rules,
|
||||
calendarService,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
|
||||
const placedRTT = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.RTT,
|
||||
).length;
|
||||
const placedCP = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.CP,
|
||||
).length;
|
||||
expect(placedRTT).toBe(2);
|
||||
expect(placedCP).toBe(1);
|
||||
|
||||
// Should have consumed 2 RTT and 1 CP
|
||||
expect(vacationDays[0].numberOfDays).toBe(1); // CP remaining
|
||||
expect(vacationDays[1].numberOfDays).toBe(0); // RTT remaining
|
||||
});
|
||||
|
||||
it('should apply if-then rules with ANY type', () => {
|
||||
const futureYear = new Date().getFullYear() + 2;
|
||||
const vacationDays: VacDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 2,
|
||||
expiryDate: new Date(futureYear + 2, 11, 31),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: SelectableDayType.RTT,
|
||||
numberOfDays: 2,
|
||||
expiryDate: new Date(futureYear + 1, 11, 31), // Expires earlier
|
||||
},
|
||||
];
|
||||
const rules: IfThenRule[] = [
|
||||
{
|
||||
id: 'i1',
|
||||
name: 'Rule',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 6, // Saturday
|
||||
placeDays: 3,
|
||||
placeType: SelectableDayType.ANY,
|
||||
placement: 'auto',
|
||||
rewardDays: 0,
|
||||
rewardType: SelectableDayType.CP,
|
||||
},
|
||||
];
|
||||
|
||||
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5);
|
||||
while (saturdayHoliday.getDay() !== 6) {
|
||||
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
|
||||
}
|
||||
selectedDates._datesSelected.push(
|
||||
new SelectedDate(
|
||||
DayType.CLOSED_DAY,
|
||||
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
|
||||
),
|
||||
);
|
||||
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setFullYear(endDate.getFullYear() + 3);
|
||||
selectedDates.applyIfThenRules(vacationDays, rules, startDate, endDate);
|
||||
|
||||
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
|
||||
const placedRTT = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.RTT,
|
||||
).length;
|
||||
const placedCP = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.CP,
|
||||
).length;
|
||||
expect(placedRTT).toBe(2);
|
||||
expect(placedCP).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import Holidays, { HolidaysTypes } from 'date-holidays';
|
||||
import dayjs from 'dayjs';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import { CalendarSettingsService } from './calendar-settings-service';
|
||||
import { DateCacheService } from './date-cache.service';
|
||||
import { VacationRule, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
|
||||
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.extend(isoWeek);
|
||||
@@ -22,21 +22,9 @@ const allTypesOptions: HolidaysTypes.Options = {
|
||||
export class CalendarService {
|
||||
private dateCache = inject(DateCacheService);
|
||||
|
||||
// Cache for holiday timestamps
|
||||
private holidayTimestampsCache: Set<number> | null = null;
|
||||
private holidayCacheYear: number | null = null;
|
||||
|
||||
// Get selected dates from current date to the next year
|
||||
getSelectedDatesFromNow(): SelectedDates {
|
||||
const _holidays = this.getHolidays();
|
||||
const selectedDates: SelectedDates = new SelectedDates();
|
||||
// Logic to populate selectedDates from now until the next year
|
||||
return selectedDates;
|
||||
}
|
||||
|
||||
getSelectedDatesForYear(): SelectedDates {
|
||||
const holidays = this.getHolidays();
|
||||
const monthInAYear = this.monthsInAYearFromNow();
|
||||
getSelectedDatesForPeriod(startDate: Date, endDate: Date): SelectedDates {
|
||||
const holidays = this.getHolidaysForPeriod(startDate, endDate);
|
||||
const monthInPeriod = this.monthsInPeriod(startDate, endDate);
|
||||
const selectedDates: SelectedDates = new SelectedDates();
|
||||
|
||||
// Use DateCacheService to get today's date components
|
||||
@@ -49,7 +37,7 @@ export class CalendarService {
|
||||
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
|
||||
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
|
||||
|
||||
for (const weeksInAMonth of monthInAYear) {
|
||||
for (const weeksInAMonth of monthInPeriod) {
|
||||
for (const daysInAWeek of weeksInAMonth) {
|
||||
for (const day of daysInAWeek) {
|
||||
if (day) {
|
||||
@@ -101,24 +89,16 @@ export class CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
getHolidays(): Date[] {
|
||||
// Cache holidays to avoid recalculation
|
||||
const currentYear = this.dateCache.getCurrentYear();
|
||||
if (this.holidayCacheYear === currentYear && this.holidayTimestampsCache) {
|
||||
// Return timestamps directly for efficient lookup
|
||||
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
|
||||
}
|
||||
|
||||
// Logic to return holidays for the given year
|
||||
getHolidaysForPeriod(startDate: Date, endDate: Date): Date[] {
|
||||
const startYear = startDate.getFullYear();
|
||||
const endYear = endDate.getFullYear();
|
||||
const holidaysDate: Date[] = [];
|
||||
const hd = new Holidays('FR', allTypesOptions);
|
||||
const holidays = hd.getHolidays(currentYear, lang);
|
||||
const holidaysPlusOne = hd.getHolidays(currentYear + 1, lang);
|
||||
holidays.push(...holidaysPlusOne);
|
||||
const holidaysDate: Date[] = holidays.map((holiday) => new Date(holiday.date));
|
||||
|
||||
// Update cache
|
||||
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
|
||||
this.holidayCacheYear = currentYear;
|
||||
for (let y = startYear; y <= endYear; y++) {
|
||||
const yearHolidays = hd.getHolidays(y, lang);
|
||||
holidaysDate.push(...yearHolidays.map((holiday) => new Date(holiday.date)));
|
||||
}
|
||||
|
||||
return holidaysDate;
|
||||
}
|
||||
@@ -167,14 +147,15 @@ export class CalendarService {
|
||||
return months;
|
||||
}
|
||||
|
||||
monthsInAYearFromNow(): Year {
|
||||
monthsInPeriod(startDate: Date, endDate: Date): Year {
|
||||
const months: Year = [];
|
||||
const currentMonthIndex = this.dateCache.getCurrentMonth();
|
||||
const currentYear = this.dateCache.getCurrentYear();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const monthIndex = (currentMonthIndex + i) % 12;
|
||||
const year = currentYear + Math.floor((currentMonthIndex + i) / 12);
|
||||
months.push(CalendarService.weeksInMonth(monthIndex, year));
|
||||
const start = dayjs(startDate).startOf('month');
|
||||
const end = dayjs(endDate).startOf('month');
|
||||
|
||||
let current = start;
|
||||
while (current.isBefore(end) || current.isSame(end, 'month')) {
|
||||
months.push(CalendarService.weeksInMonth(current.month(), current.year()));
|
||||
current = current.add(1, 'month');
|
||||
}
|
||||
return months;
|
||||
}
|
||||
@@ -208,15 +189,17 @@ export enum SelectableDayType {
|
||||
CP = DayType.CP,
|
||||
RTT = DayType.RTT,
|
||||
OTHER = DayType.OTHER,
|
||||
ANY = 'any',
|
||||
}
|
||||
|
||||
export const tooltipTypeMapping: { [key in DayType]: string } = {
|
||||
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
|
||||
[DayType.WEEKEND]: 'Weekend',
|
||||
[DayType.CLOSED_DAY]: 'Jour Férié',
|
||||
[DayType.RTT]: 'RTT',
|
||||
[DayType.CP]: 'Congé Payé',
|
||||
[DayType.OTHER]: 'Autre',
|
||||
[DayType.TODAY]: "Aujourd'hui",
|
||||
[SelectableDayType.ANY]: "N'importe",
|
||||
};
|
||||
|
||||
export const monthNames: string[] = [
|
||||
@@ -305,7 +288,6 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
|
||||
// Constant to indicate no valid strategy was found
|
||||
private static readonly NO_VALID_STRATEGY_HEURISTIC = -1;
|
||||
private static readonly MAX_DAYS_WITH_SAME_HEURISTIC = 9;
|
||||
|
||||
update(): void {
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
@@ -545,16 +527,33 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
|
||||
async optimizeVacations(
|
||||
vacationDays: VacDay[],
|
||||
calendarSettingsService: CalendarSettingsService,
|
||||
rules: VacationRule[],
|
||||
calendarService: CalendarService,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
progressCallback?: (progress: number) => void,
|
||||
): Promise<VacDay[]> {
|
||||
if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationDays);
|
||||
|
||||
// Calculate total days to process
|
||||
const totalDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
let processedDays = 0;
|
||||
|
||||
// Execute custom rules sequentially in the exact order they appear in the list
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
if (rule.type === RuleType.TIME_WINDOW) {
|
||||
this.applyTimeWindowRules(
|
||||
vacationDays,
|
||||
[rule as TimeWindowRule],
|
||||
calendarService,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
} else if (rule.type === RuleType.IF_THEN) {
|
||||
this.applyIfThenRules(vacationDays, [rule as IfThenRule], startDate, endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to update progress, throttled to every 5%
|
||||
let lastReportedProgress = 0;
|
||||
const PROGRESS_STEP = 5; // percent
|
||||
@@ -587,7 +586,7 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
}
|
||||
|
||||
const vacDayToUse = sortedVacDays[0];
|
||||
const result = this.lookForVacation(vacDayToUse, calendarService);
|
||||
const result = this.lookForVacation(vacDayToUse, calendarService, startDate, endDate);
|
||||
|
||||
if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) {
|
||||
// Mark this VacDay as exhausted and try other vacation days
|
||||
@@ -617,209 +616,326 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
return vacationDays;
|
||||
}
|
||||
|
||||
samediMalin(vacationDays: VacDay[]): void {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const nowTime = now.getTime();
|
||||
applyTimeWindowRules(
|
||||
vacationDays: VacDay[],
|
||||
rules: TimeWindowRule[],
|
||||
calendarService: CalendarService,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): void {
|
||||
for (const rule of rules) {
|
||||
let daysToPlace = rule.placeDays;
|
||||
|
||||
const samedi_ferie = this._datesSelected.filter(
|
||||
(date) => date.type === DayType.CLOSED_DAY && date.range.start?.getDay() === 6,
|
||||
);
|
||||
const consumeDays = (count: number): boolean => {
|
||||
let needed = count;
|
||||
const availableDays = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
(rule.placeType === SelectableDayType.ANY ||
|
||||
vd.type === rule.placeType) &&
|
||||
vd.numberOfDays > 0,
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
|
||||
// Helper function to get available CP days that don't expire before a given date (ordered by expiry date)
|
||||
const getAvailableCpDaysForDate = (useByDate: Date): VacDay[] => {
|
||||
for (const vd of availableDays) {
|
||||
if (needed === 0) break;
|
||||
const take = Math.min(vd.numberOfDays, needed);
|
||||
vd.numberOfDays -= take;
|
||||
needed -= take;
|
||||
}
|
||||
return needed === 0;
|
||||
};
|
||||
|
||||
const totalAvailable = vacationDays
|
||||
.filter(
|
||||
(vd) => rule.placeType === SelectableDayType.ANY || vd.type === rule.placeType,
|
||||
)
|
||||
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
|
||||
if (totalAvailable < daysToPlace) {
|
||||
console.warn(
|
||||
`Insufficient ${rule.placeType} days for Time Window Rule "${rule.name}". Needs ${daysToPlace}, has ${totalAvailable}. Placing ${totalAvailable} days instead.`,
|
||||
);
|
||||
daysToPlace = totalAvailable;
|
||||
}
|
||||
|
||||
if (daysToPlace <= 0) continue;
|
||||
|
||||
const getNextAvailableType = (): DayType => {
|
||||
const sorted = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
(rule.placeType === SelectableDayType.ANY ||
|
||||
vd.type === rule.placeType) &&
|
||||
vd.numberOfDays > 0,
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
return sorted.length > 0
|
||||
? (sorted[0].type as unknown as DayType)
|
||||
: ((rule.placeType === SelectableDayType.ANY
|
||||
? SelectableDayType.CP
|
||||
: rule.placeType) as unknown as DayType);
|
||||
};
|
||||
|
||||
const candidates: Date[] = [];
|
||||
const daysOfYear = calendarService.monthsInPeriod(startDate, endDate);
|
||||
const start = new Date(rule.startDate).getTime();
|
||||
const end = new Date(rule.endDate).getTime();
|
||||
const startLimit = startDate.getTime();
|
||||
const endLimit = endDate.getTime();
|
||||
|
||||
for (const month of daysOfYear) {
|
||||
for (const week of month) {
|
||||
for (const day of week) {
|
||||
if (day) {
|
||||
const dayTime = day.getTime();
|
||||
if (
|
||||
dayTime >= start &&
|
||||
dayTime <= end &&
|
||||
dayTime >= startLimit &&
|
||||
dayTime <= endLimit &&
|
||||
!this.isSelected(day)
|
||||
) {
|
||||
candidates.push(day);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < daysToPlace; i++) {
|
||||
if (candidates.length === 0) break;
|
||||
|
||||
const currentPlaceType = getNextAvailableType();
|
||||
let bestCandidate: Date | null = null;
|
||||
let bestHeuristic = -Infinity;
|
||||
let bestCandidateIndex = -1;
|
||||
|
||||
for (let j = 0; j < candidates.length; j++) {
|
||||
const day = candidates[j];
|
||||
if (this.isSelected(day)) continue;
|
||||
|
||||
const tempDatesBackup = [...this._datesSelected];
|
||||
this._datesSelected.push(
|
||||
new SelectedDate(currentPlaceType, new DateRange<Date>(day, day)),
|
||||
);
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
|
||||
const heuristic = this.computeHeuristics();
|
||||
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
|
||||
if (heuristic > bestHeuristic) {
|
||||
bestHeuristic = heuristic;
|
||||
bestCandidate = day;
|
||||
bestCandidateIndex = j;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCandidate) {
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
currentPlaceType,
|
||||
new DateRange<Date>(bestCandidate, bestCandidate),
|
||||
),
|
||||
);
|
||||
consumeDays(1);
|
||||
candidates.splice(bestCandidateIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyIfThenRules(
|
||||
vacationDays: VacDay[],
|
||||
rules: IfThenRule[],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): void {
|
||||
const nowTime = new Date().setHours(0, 0, 0, 0);
|
||||
const startLimit = Math.max(nowTime, startDate.getTime());
|
||||
const endLimit = endDate.getTime();
|
||||
|
||||
const consumeDays = (count: number, type: SelectableDayType, useByDate: Date): boolean => {
|
||||
let daysToConsume = count;
|
||||
const useByTime = useByDate.getTime();
|
||||
return vacationDays
|
||||
const availableDays = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
vd.type === SelectableDayType.CP &&
|
||||
(type === SelectableDayType.ANY || vd.type === type) &&
|
||||
vd.numberOfDays > 0 &&
|
||||
vd.expiryDate.getTime() >= useByTime,
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
};
|
||||
|
||||
// Helper function to get total available CP days that don't expire before a given date
|
||||
const getTotalCpDaysForDate = (useByDate: Date): number => {
|
||||
const useByTime = useByDate.getTime();
|
||||
return vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
vd.type === SelectableDayType.CP && vd.expiryDate.getTime() >= useByTime,
|
||||
)
|
||||
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
};
|
||||
|
||||
// Helper function to consume 2 CP days from the available pool for a specific date
|
||||
const consume2CpDaysForDate = (useByDate: Date): boolean => {
|
||||
let daysToConsume = 2;
|
||||
const cpDays = getAvailableCpDaysForDate(useByDate);
|
||||
for (const cpDay of cpDays) {
|
||||
for (const vd of availableDays) {
|
||||
if (daysToConsume === 0) break;
|
||||
const toConsume = Math.min(cpDay.numberOfDays, daysToConsume);
|
||||
cpDay.numberOfDays -= toConsume;
|
||||
daysToConsume -= toConsume;
|
||||
const take = Math.min(vd.numberOfDays, daysToConsume);
|
||||
vd.numberOfDays -= take;
|
||||
daysToConsume -= take;
|
||||
}
|
||||
return daysToConsume === 0;
|
||||
};
|
||||
|
||||
// Helper function to add 1 OTHER day as reward
|
||||
const addOtherDayReward = (): void => {
|
||||
// Find an existing OTHER VacDay or create a new one
|
||||
const otherDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER);
|
||||
if (otherDay) {
|
||||
otherDay.numberOfDays += 1;
|
||||
const checkAvailableDays = (
|
||||
count: number,
|
||||
type: SelectableDayType,
|
||||
useByDate: Date,
|
||||
): boolean => {
|
||||
const useByTime = useByDate.getTime();
|
||||
const total = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
(type === SelectableDayType.ANY || vd.type === type) &&
|
||||
vd.expiryDate.getTime() >= useByTime,
|
||||
)
|
||||
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
return total >= count;
|
||||
};
|
||||
|
||||
const addRewardDays = (count: number, type: SelectableDayType): void => {
|
||||
if (count <= 0) return;
|
||||
const existing = vacationDays.find((vd) => vd.type === type);
|
||||
if (existing) {
|
||||
existing.numberOfDays += count;
|
||||
} else {
|
||||
// Create a new OTHER VacDay with a far future expiry date
|
||||
vacationDays.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: SelectableDayType.OTHER,
|
||||
numberOfDays: 1,
|
||||
expiryDate: new Date(now.getFullYear() + 10, 11, 31),
|
||||
type: type,
|
||||
numberOfDays: count,
|
||||
expiryDate: new Date(new Date().getFullYear() + 10, 11, 31),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const holiday of samedi_ferie) {
|
||||
if (!holiday.range.start) continue;
|
||||
|
||||
// Get the Saturday date (holiday) and the following Sunday
|
||||
const saturdayDate = new Date(holiday.range.start);
|
||||
const sundayDate = new Date(saturdayDate);
|
||||
sundayDate.setDate(saturdayDate.getDate() + 1);
|
||||
|
||||
// Calculate dates for all three strategies (2 CP days total each)
|
||||
// Strategy 1: 2 days before (Thursday + Friday)
|
||||
const twoDaysBefore1 = new Date(saturdayDate);
|
||||
twoDaysBefore1.setDate(saturdayDate.getDate() - 2);
|
||||
const twoDaysBefore2 = new Date(saturdayDate);
|
||||
twoDaysBefore2.setDate(saturdayDate.getDate() - 1);
|
||||
|
||||
// Strategy 2: 2 days after (Monday + Tuesday)
|
||||
const twoDaysAfter1 = new Date(sundayDate);
|
||||
twoDaysAfter1.setDate(sundayDate.getDate() + 1);
|
||||
const twoDaysAfter2 = new Date(sundayDate);
|
||||
twoDaysAfter2.setDate(sundayDate.getDate() + 2);
|
||||
|
||||
// Strategy 3: 1 day before + 1 day after (Friday + Monday)
|
||||
const oneDayBefore = new Date(saturdayDate);
|
||||
oneDayBefore.setDate(saturdayDate.getDate() - 1);
|
||||
const oneDayAfter = new Date(sundayDate);
|
||||
oneDayAfter.setDate(sundayDate.getDate() + 1);
|
||||
|
||||
// Check if we can apply each strategy (not in the past, not already selected, and have non-expired CP days)
|
||||
const canApplyTwoBeforeStrategy =
|
||||
twoDaysBefore1.getTime() >= nowTime &&
|
||||
!this.isSelected(twoDaysBefore1) &&
|
||||
!this.isSelected(twoDaysBefore2) &&
|
||||
getTotalCpDaysForDate(twoDaysBefore2) >= 2; // Check expiry against the latest date used
|
||||
|
||||
const canApplyTwoAfterStrategy =
|
||||
twoDaysAfter1.getTime() >= nowTime &&
|
||||
!this.isSelected(twoDaysAfter1) &&
|
||||
!this.isSelected(twoDaysAfter2) &&
|
||||
getTotalCpDaysForDate(twoDaysAfter2) >= 2; // Check expiry against the latest date used
|
||||
|
||||
const canApplyMixedStrategy =
|
||||
oneDayBefore.getTime() >= nowTime &&
|
||||
!this.isSelected(oneDayBefore) &&
|
||||
!this.isSelected(oneDayAfter) &&
|
||||
getTotalCpDaysForDate(oneDayAfter) >= 2; // Check expiry against the latest date used
|
||||
|
||||
// Test all applicable strategies and choose the one with the best heuristic score
|
||||
const strategies: { apply: () => void; heuristic: number }[] = [];
|
||||
const tempDatesBackup = [...this._datesSelected];
|
||||
|
||||
if (canApplyTwoBeforeStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
|
||||
),
|
||||
const getWorkingDays = (startDate: Date, direction: 1 | -1, count: number): Date[] => {
|
||||
const result: Date[] = [];
|
||||
const current = new Date(startDate);
|
||||
while (result.length < count) {
|
||||
current.setDate(current.getDate() + direction);
|
||||
const isWeekend = current.getDay() === 0 || current.getDay() === 6;
|
||||
const isHoliday = this._datesSelected.some(
|
||||
(d) =>
|
||||
d.type === DayType.CLOSED_DAY &&
|
||||
d.range.start?.getTime() === current.getTime(),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(twoDaysBefore2);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
if (!isWeekend && !isHoliday) {
|
||||
result.push(new Date(current));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
if (canApplyTwoAfterStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(twoDaysAfter1, twoDaysAfter2)),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysAfter1, twoDaysAfter2),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(twoDaysAfter2);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
}
|
||||
for (const rule of rules) {
|
||||
const matchingHolidays = this._datesSelected.filter(
|
||||
(date) =>
|
||||
date.type === DayType.CLOSED_DAY &&
|
||||
date.range.start?.getDay() === rule.dayOfWeek,
|
||||
);
|
||||
|
||||
if (canApplyMixedStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(oneDayBefore, oneDayBefore)),
|
||||
);
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(oneDayAfter, oneDayAfter)),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(oneDayBefore, oneDayBefore),
|
||||
),
|
||||
);
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(oneDayAfter, oneDayAfter),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(oneDayAfter);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
}
|
||||
for (const holiday of matchingHolidays) {
|
||||
if (!holiday.range.start) continue;
|
||||
const holidayDate = new Date(holiday.range.start);
|
||||
|
||||
// Apply the strategy with the best heuristic score
|
||||
if (strategies.length > 0) {
|
||||
const bestStrategy = strategies.reduce((best, current) =>
|
||||
current.heuristic > best.heuristic ? current : best,
|
||||
);
|
||||
bestStrategy.apply();
|
||||
const N = rule.placeDays;
|
||||
if (N <= 0) continue;
|
||||
|
||||
const placementOptions: Date[][] = [];
|
||||
|
||||
if (rule.placement === 'before' || rule.placement === 'auto') {
|
||||
placementOptions.push(getWorkingDays(holidayDate, -1, N));
|
||||
}
|
||||
if (rule.placement === 'after' || rule.placement === 'auto') {
|
||||
placementOptions.push(getWorkingDays(holidayDate, 1, N));
|
||||
}
|
||||
if (rule.placement === 'around' || rule.placement === 'auto') {
|
||||
const beforeCount = Math.ceil(N / 2);
|
||||
const afterCount = Math.floor(N / 2);
|
||||
const aroundDates = [
|
||||
...getWorkingDays(holidayDate, -1, beforeCount),
|
||||
...getWorkingDays(holidayDate, 1, afterCount),
|
||||
];
|
||||
placementOptions.push(aroundDates);
|
||||
}
|
||||
|
||||
const strategies: { apply: () => void; heuristic: number }[] = [];
|
||||
const tempDatesBackup = [...this._datesSelected];
|
||||
|
||||
for (const dates of placementOptions) {
|
||||
const allValid = dates.every(
|
||||
(d) =>
|
||||
d.getTime() >= startLimit &&
|
||||
d.getTime() <= endLimit &&
|
||||
!this.isSelected(d),
|
||||
);
|
||||
if (!allValid) continue;
|
||||
|
||||
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
|
||||
if (!checkAvailableDays(N, rule.placeType, maxDate)) continue;
|
||||
|
||||
const simulatedDays = vacationDays.map((vd) => ({ ...vd }));
|
||||
const datesWithTypes: { date: Date; type: DayType }[] = [];
|
||||
for (const d of dates) {
|
||||
const sorted = simulatedDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
(rule.placeType === SelectableDayType.ANY ||
|
||||
vd.type === rule.placeType) &&
|
||||
vd.numberOfDays > 0 &&
|
||||
vd.expiryDate.getTime() >= maxDate.getTime(),
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
|
||||
let chosenType = (rule.placeType === SelectableDayType.ANY
|
||||
? SelectableDayType.CP
|
||||
: rule.placeType) as unknown as DayType;
|
||||
if (sorted.length > 0) {
|
||||
sorted[0].numberOfDays--;
|
||||
chosenType = sorted[0].type as unknown as DayType;
|
||||
}
|
||||
datesWithTypes.push({ date: d, type: chosenType });
|
||||
}
|
||||
|
||||
for (const dt of datesWithTypes) {
|
||||
this.push(new SelectedDate(dt.type, new DateRange<Date>(dt.date, dt.date)));
|
||||
}
|
||||
const heuristic = this.computeHeuristics();
|
||||
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
for (const dt of datesWithTypes) {
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
dt.type,
|
||||
new DateRange<Date>(dt.date, dt.date),
|
||||
),
|
||||
);
|
||||
}
|
||||
consumeDays(N, rule.placeType, maxDate);
|
||||
addRewardDays(rule.rewardDays, rule.rewardType);
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
}
|
||||
|
||||
if (strategies.length > 0) {
|
||||
const bestStrategy = strategies.reduce((best, current) =>
|
||||
current.heuristic > best.heuristic ? current : best,
|
||||
);
|
||||
bestStrategy.apply();
|
||||
} else {
|
||||
console.warn(
|
||||
`Could not apply If-Then Rule "${rule.name}" for holiday on ${holidayDate.toLocaleDateString()}. Candidates invalid or insufficient days.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -827,11 +943,15 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
lookForVacation(
|
||||
vacDay: VacDay,
|
||||
calendarService: CalendarService,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): { apply: () => void; heuristic: number } {
|
||||
const daysOfYear = calendarService.monthsInAYearFromNow();
|
||||
const daysOfYear = calendarService.monthsInPeriod(startDate, endDate);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const nowTime = now.getTime();
|
||||
const startLimit = Math.max(nowTime, startDate.getTime());
|
||||
const endLimit = endDate.getTime();
|
||||
const expiryTime = vacDay.expiryDate.getTime();
|
||||
|
||||
// Preprocess: filter out past days, already selected days, and days after expiry date
|
||||
@@ -842,7 +962,12 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
if (day) {
|
||||
day.setHours(0, 0, 0, 0);
|
||||
const dayTime = day.getTime();
|
||||
if (dayTime >= nowTime && dayTime <= expiryTime && !this.isSelected(day)) {
|
||||
if (
|
||||
dayTime >= startLimit &&
|
||||
dayTime <= endLimit &&
|
||||
dayTime <= expiryTime &&
|
||||
!this.isSelected(day)
|
||||
) {
|
||||
candidateDays.push(day);
|
||||
}
|
||||
}
|
||||
@@ -859,7 +984,6 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
const tempDatesBackup = [...this._datesSelected];
|
||||
let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null;
|
||||
let bestHeuristic = -Infinity;
|
||||
let daysWithBestHeuristic = 0;
|
||||
|
||||
// Evaluate each candidate day
|
||||
for (const day of candidateDays) {
|
||||
@@ -876,7 +1000,6 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
// Track only if better than current best
|
||||
if (heuristic > bestHeuristic) {
|
||||
bestHeuristic = heuristic;
|
||||
daysWithBestHeuristic = 1;
|
||||
const capturedDay = day;
|
||||
bestStrategy = {
|
||||
apply: () => {
|
||||
@@ -891,25 +1014,9 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
heuristic: heuristic,
|
||||
value: capturedDay,
|
||||
};
|
||||
} else if (heuristic === bestHeuristic) {
|
||||
daysWithBestHeuristic++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if too many days have the same best heuristic after evaluating all days
|
||||
if (daysWithBestHeuristic > SelectedDates.MAX_DAYS_WITH_SAME_HEURISTIC) {
|
||||
// Restore backup before stopping
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
|
||||
// Stop optimization - let user decide manually
|
||||
return {
|
||||
apply: () => {},
|
||||
heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC,
|
||||
};
|
||||
}
|
||||
|
||||
// Restore backup before returning
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
|
||||
@@ -18,7 +18,6 @@ describe('CalendarSettingsService', () => {
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
expect(service.get('showWeekNumbers')()).toBe(false);
|
||||
expect(service.get('samediMalin')()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow updating showWeekNumbers', () => {
|
||||
@@ -26,20 +25,13 @@ describe('CalendarSettingsService', () => {
|
||||
expect(service.get('showWeekNumbers')()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow updating samediMalin', () => {
|
||||
service.get('samediMalin').set(false);
|
||||
expect(service.get('samediMalin')()).toBe(false);
|
||||
});
|
||||
|
||||
it('should provide settings array for UI iteration', () => {
|
||||
const settingsArray = service.settingsArray;
|
||||
expect(settingsArray.length).toBe(2);
|
||||
expect(settingsArray.length).toBe(1);
|
||||
expect(settingsArray[0].id).toBe('showWeekNumbers');
|
||||
expect(settingsArray[1].id).toBe('samediMalin');
|
||||
});
|
||||
|
||||
it('should return correct descriptions', () => {
|
||||
expect(service.getDescription('showWeekNumbers')).toBe('Afficher les numéros de semaine');
|
||||
expect(service.getDescription('samediMalin')).toBe('Activer le Samedi Malin');
|
||||
});
|
||||
});
|
||||
@@ -20,13 +20,6 @@ export class CalendarSettingsService {
|
||||
description: 'Afficher les numéros de semaine',
|
||||
},
|
||||
],
|
||||
[
|
||||
'samediMalin',
|
||||
{
|
||||
defaultValue: true,
|
||||
description: 'Activer le Samedi Malin',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
private readonly signals = new Map<string, WritableSignal<boolean>>();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RuleService, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
|
||||
describe('RuleService', () => {
|
||||
let service: RuleService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test to ensure a clean state
|
||||
localStorage.clear();
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(RuleService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with default rules', () => {
|
||||
const rules = service.rules;
|
||||
expect(rules.length).toBe(2);
|
||||
expect(rules[0].id).toBe('default-samedi-malin');
|
||||
expect(rules[1].id).toBe('default-christmas');
|
||||
});
|
||||
|
||||
it('should parse dates correctly for TimeWindowRules', () => {
|
||||
const rules = service.rules;
|
||||
const christmasRule = rules.find((r) => r.type === RuleType.TIME_WINDOW) as TimeWindowRule;
|
||||
expect(christmasRule).toBeDefined();
|
||||
expect(christmasRule.startDate instanceof Date).toBe(true);
|
||||
expect(christmasRule.endDate instanceof Date).toBe(true);
|
||||
});
|
||||
|
||||
it('should add a new rule', () => {
|
||||
const newRule: IfThenRule = {
|
||||
id: 'test-rule',
|
||||
name: 'Test Rule',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 1, // Monday
|
||||
placeDays: 1,
|
||||
placeType: SelectableDayType.RTT,
|
||||
placement: 'after',
|
||||
rewardDays: 0,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
};
|
||||
|
||||
service.addRule(newRule);
|
||||
const rules = service.rules;
|
||||
expect(rules.length).toBe(3);
|
||||
expect(rules.find((r) => r.id === 'test-rule')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update an existing rule', () => {
|
||||
const rules = service.rules;
|
||||
const targetRule = { ...rules[0] } as IfThenRule;
|
||||
targetRule.name = 'Updated Name';
|
||||
|
||||
service.updateRule(targetRule);
|
||||
|
||||
const updatedRules = service.rules;
|
||||
const updated = updatedRules.find((r) => r.id === targetRule.id);
|
||||
expect(updated?.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should delete a rule', () => {
|
||||
const rules = service.rules;
|
||||
const idToDelete = rules[0].id;
|
||||
|
||||
service.deleteRule(idToDelete);
|
||||
|
||||
const updatedRules = service.rules;
|
||||
expect(updatedRules.length).toBe(1);
|
||||
expect(updatedRules.find((r) => r.id === idToDelete)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should toggle rule enabled state', () => {
|
||||
const targetId = service.rules[0].id;
|
||||
const initialState = service.rules[0].enabled;
|
||||
|
||||
service.toggleRule(targetId);
|
||||
|
||||
expect(service.rules.find((r) => r.id === targetId)?.enabled).toBe(!initialState);
|
||||
});
|
||||
|
||||
it('should reorder rules correctly', () => {
|
||||
const rulesBefore = service.rules;
|
||||
const firstRuleId = rulesBefore[0].id;
|
||||
const secondRuleId = rulesBefore[1].id;
|
||||
|
||||
// Move first rule to second position
|
||||
service.reorderRules(0, 1);
|
||||
|
||||
const rulesAfter = service.rules;
|
||||
expect(rulesAfter[0].id).toBe(secondRuleId);
|
||||
expect(rulesAfter[1].id).toBe(firstRuleId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Injectable, WritableSignal, Injector, inject } from '@angular/core';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
export enum RuleType {
|
||||
IF_THEN = 'if_then',
|
||||
TIME_WINDOW = 'time_window',
|
||||
}
|
||||
|
||||
export interface BaseRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type PlacementStrategy = 'before' | 'after' | 'around' | 'auto';
|
||||
|
||||
export interface IfThenRule extends BaseRule {
|
||||
type: RuleType.IF_THEN;
|
||||
dayOfWeek: number; // 0-6, where 0 is Sunday, 6 is Saturday
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
placement: PlacementStrategy;
|
||||
rewardDays: number;
|
||||
rewardType: SelectableDayType;
|
||||
}
|
||||
|
||||
export interface TimeWindowRule extends BaseRule {
|
||||
type: RuleType.TIME_WINDOW;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
}
|
||||
|
||||
export type VacationRule = IfThenRule | TimeWindowRule;
|
||||
|
||||
interface SerializedRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
enabled: boolean;
|
||||
dayOfWeek?: number;
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
placement?: PlacementStrategy;
|
||||
rewardDays?: number;
|
||||
rewardType?: SelectableDayType;
|
||||
startDate?: string | Date;
|
||||
endDate?: string | Date;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RuleService {
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
private readonly defaultRules: VacationRule[] = [
|
||||
{
|
||||
id: 'default-samedi-malin',
|
||||
name: 'Samedi Malin',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 6, // Saturday
|
||||
placeDays: 2,
|
||||
placeType: SelectableDayType.CP,
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
},
|
||||
{
|
||||
id: 'default-christmas',
|
||||
name: 'Vacances de Noël',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
enabled: true,
|
||||
startDate: new Date(new Date().getFullYear(), 11, 20), // Dec 20
|
||||
endDate: new Date(new Date().getFullYear() + 1, 0, 3), // Jan 3 next year
|
||||
placeDays: 5,
|
||||
placeType: SelectableDayType.ANY,
|
||||
},
|
||||
];
|
||||
|
||||
private rawRulesSignal: WritableSignal<SerializedRule[]>;
|
||||
|
||||
constructor() {
|
||||
this.rawRulesSignal = storedSignal<SerializedRule[]>(
|
||||
'vacationRules',
|
||||
this.defaultRules as unknown as SerializedRule[],
|
||||
this.injector,
|
||||
);
|
||||
}
|
||||
|
||||
public get rules(): VacationRule[] {
|
||||
const raw = this.rawRulesSignal();
|
||||
return raw.map((rule) => {
|
||||
if (rule.type === RuleType.TIME_WINDOW) {
|
||||
return {
|
||||
...rule,
|
||||
startDate: new Date(rule.startDate as string),
|
||||
endDate: new Date(rule.endDate as string),
|
||||
} as TimeWindowRule;
|
||||
}
|
||||
return rule as IfThenRule;
|
||||
});
|
||||
}
|
||||
|
||||
public addRule(rule: VacationRule): void {
|
||||
this.rawRulesSignal.update((rules) => [...rules, rule as unknown as SerializedRule]);
|
||||
}
|
||||
|
||||
public updateRule(updatedRule: VacationRule): void {
|
||||
this.rawRulesSignal.update((rules) =>
|
||||
rules.map((rule) =>
|
||||
rule.id === updatedRule.id ? (updatedRule as unknown as SerializedRule) : rule,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public deleteRule(id: string): void {
|
||||
this.rawRulesSignal.update((rules) => rules.filter((rule) => rule.id !== id));
|
||||
}
|
||||
|
||||
public toggleRule(id: string): void {
|
||||
this.rawRulesSignal.update((rules) =>
|
||||
rules.map((rule) => (rule.id === id ? { ...rule, enabled: !rule.enabled } : rule)),
|
||||
);
|
||||
}
|
||||
|
||||
public reorderRules(previousIndex: number, currentIndex: number): void {
|
||||
this.rawRulesSignal.update((rules) => {
|
||||
const updatedRules = [...rules];
|
||||
const [movedRule] = updatedRules.splice(previousIndex, 1);
|
||||
updatedRules.splice(currentIndex, 0, movedRule);
|
||||
return updatedRules;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { TranslationService } from './translation-service';
|
||||
|
||||
describe('TranslationService', () => {
|
||||
let service: TranslationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
});
|
||||
service = TestBed.inject(TranslationService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get correct locale', () => {
|
||||
expect(service.locale()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set language if available', () => {
|
||||
service.setLanguage('en');
|
||||
expect(service.language()).toBe('en');
|
||||
|
||||
service.setLanguage('invalid-lang');
|
||||
expect(service.language()).toBe('en'); // Should not change
|
||||
});
|
||||
|
||||
it('should get language name', () => {
|
||||
const enName = service.getLanguageName('en');
|
||||
expect(enName).toBe('English');
|
||||
});
|
||||
|
||||
it('should get language name fallback', () => {
|
||||
const origIntl = global.Intl;
|
||||
Object.defineProperty(global, 'Intl', {
|
||||
value: {
|
||||
DisplayNames: class {
|
||||
of() {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(service.getLanguageName('test')).toBe('TEST');
|
||||
|
||||
Object.defineProperty(global, 'Intl', {
|
||||
value: origIntl,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get day type label', () => {
|
||||
expect(service.getDayTypeLabel('cp')).toBeDefined();
|
||||
expect(service.getDayTypeLabel('rtt')).toBeDefined();
|
||||
expect(service.getDayTypeLabel('weekend')).toBeDefined();
|
||||
expect(service.getDayTypeLabel('closed-day')).toBeDefined();
|
||||
expect(service.getDayTypeLabel('other')).toBeDefined();
|
||||
expect(service.getDayTypeLabel('today')).toBeDefined();
|
||||
expect(service.getDayTypeLabel('any')).toBeDefined();
|
||||
expect(service.getDayTypeLabel('unknown-type')).toBe('unknown-type');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { Injectable, inject, Injector, computed } from '@angular/core';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
export interface AppTranslations {
|
||||
locale: string;
|
||||
app: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
userInput: {
|
||||
addVacationDay: string;
|
||||
calculate: string;
|
||||
remainingDaysWarning: string;
|
||||
remainingDaysManualPlace: string;
|
||||
chooseDate: string;
|
||||
daySuffix: string;
|
||||
daysSuffix: string;
|
||||
expires: string;
|
||||
choosePeriod: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
dayTypes: {
|
||||
weekend: string;
|
||||
closedDay: string;
|
||||
rtt: string;
|
||||
cp: string;
|
||||
other: string;
|
||||
today: string;
|
||||
any: string;
|
||||
};
|
||||
settings: {
|
||||
title: string;
|
||||
description: string;
|
||||
showWeekNumbers: string;
|
||||
close: string;
|
||||
selectLanguage: string;
|
||||
};
|
||||
rules: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
priorityInfo: string;
|
||||
ifThenTemplate: string;
|
||||
timeWindowTemplate: string;
|
||||
editRule: string;
|
||||
addRule: string;
|
||||
place: string;
|
||||
ruleName: string;
|
||||
holidayFalls: string;
|
||||
days: string;
|
||||
type: string;
|
||||
position: string;
|
||||
getBonus: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
cancel: string;
|
||||
save: string;
|
||||
add: string;
|
||||
addRuleButton: string;
|
||||
newIfThenRule: string;
|
||||
newTimeWindowRule: string;
|
||||
defaultNewRuleName: string;
|
||||
defaultNewPeriodName: string;
|
||||
daysOfWeek: {
|
||||
1: string;
|
||||
2: string;
|
||||
3: string;
|
||||
4: string;
|
||||
5: string;
|
||||
6: string;
|
||||
0: string;
|
||||
};
|
||||
placement: {
|
||||
before: string;
|
||||
after: string;
|
||||
around: string;
|
||||
auto: string;
|
||||
};
|
||||
};
|
||||
loading: string;
|
||||
weekdaysShort: string[];
|
||||
}
|
||||
|
||||
import { translationsMap, availableLanguagesList } from '../i18n-registry';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TranslationService {
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
public readonly availableLanguages = availableLanguagesList;
|
||||
|
||||
// Detect browser language, fallback to the first available language
|
||||
private getBrowserLanguage(): string {
|
||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||
const primary = navigator.language.split('-')[0].toLowerCase();
|
||||
if (this.availableLanguages.includes(primary)) {
|
||||
return primary;
|
||||
}
|
||||
}
|
||||
return this.availableLanguages[0] || '';
|
||||
}
|
||||
|
||||
public readonly language = storedSignal<string>(
|
||||
'app.language',
|
||||
this.getBrowserLanguage(),
|
||||
this.injector,
|
||||
);
|
||||
|
||||
public readonly tr = computed<AppTranslations>(() => {
|
||||
const currentLang = this.language();
|
||||
const fallbackLang = this.availableLanguages[0] || '';
|
||||
return (
|
||||
(translationsMap[currentLang] as AppTranslations) ||
|
||||
(translationsMap[fallbackLang] as AppTranslations) ||
|
||||
({} as AppTranslations)
|
||||
);
|
||||
});
|
||||
|
||||
public readonly locale = computed<string>(() => {
|
||||
const tr = this.tr();
|
||||
if (tr && tr.locale) {
|
||||
return tr.locale;
|
||||
}
|
||||
const lang = this.language();
|
||||
if (lang) {
|
||||
return `${lang}-${lang.toUpperCase()}`;
|
||||
}
|
||||
const fallbackLang = this.availableLanguages[0];
|
||||
if (fallbackLang) {
|
||||
return `${fallbackLang}-${fallbackLang.toUpperCase()}`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
public setLanguage(lang: string): void {
|
||||
if (this.availableLanguages.includes(lang)) {
|
||||
this.language.set(lang);
|
||||
}
|
||||
}
|
||||
|
||||
public getLanguageName(langCode: string): string {
|
||||
try {
|
||||
// Using native Intl.DisplayNames to get name of language in its own language
|
||||
const displayNames = new Intl.DisplayNames([langCode], { type: 'language' });
|
||||
const name = displayNames.of(langCode);
|
||||
if (name) {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to uppercase language code
|
||||
}
|
||||
return langCode.toUpperCase();
|
||||
}
|
||||
|
||||
public getDayTypeLabel(type: string): string {
|
||||
const tr = this.tr();
|
||||
if (!tr || !tr.dayTypes) {
|
||||
return type;
|
||||
}
|
||||
switch (type) {
|
||||
case 'weekend':
|
||||
return tr.dayTypes.weekend || type;
|
||||
case 'closed-day':
|
||||
return tr.dayTypes.closedDay || type;
|
||||
case 'rtt':
|
||||
return tr.dayTypes.rtt || type;
|
||||
case 'cp':
|
||||
return tr.dayTypes.cp || type;
|
||||
case 'other':
|
||||
return tr.dayTypes.other || type;
|
||||
case 'today':
|
||||
return tr.dayTypes.today || type;
|
||||
case 'any':
|
||||
return tr.dayTypes.any || type;
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,4 +54,24 @@ describe('UserInputService', () => {
|
||||
expect(newDay.numberOfDays).toBe(1);
|
||||
expect(newDay.expiryDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should set start date', () => {
|
||||
const newDate = new Date();
|
||||
newDate.setFullYear(newDate.getFullYear() + 2);
|
||||
service.setStartDate(newDate);
|
||||
expect(service.startDate().getFullYear()).toBe(newDate.getFullYear());
|
||||
});
|
||||
|
||||
it('should set end date', () => {
|
||||
const newDate = new Date();
|
||||
newDate.setFullYear(newDate.getFullYear() + 3);
|
||||
service.setEndDate(newDate);
|
||||
expect(service.endDate().getFullYear()).toBe(newDate.getFullYear());
|
||||
});
|
||||
|
||||
it('should request computation', () => {
|
||||
const initialValue = service.computeTriggerSignal();
|
||||
service.requestComputation();
|
||||
expect(service.computeTriggerSignal()).toBe(initialValue + 1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Injectable, signal, computed, inject, Injector } from '@angular/core';
|
||||
import { SelectableDayType, VacDay } from './calendar-service';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -15,20 +16,19 @@ export class UserInputService {
|
||||
id: crypto.randomUUID(),
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: this.getRandomNumber(),
|
||||
// today + 365 days
|
||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||
expiryDate: UserInputService.getOneYearFromTodayMidnight(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: SelectableDayType.RTT,
|
||||
numberOfDays: this.getRandomNumber(),
|
||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||
expiryDate: UserInputService.getOneYearFromTodayMidnight(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type: SelectableDayType.OTHER,
|
||||
numberOfDays: this.getRandomNumber(),
|
||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||
expiryDate: UserInputService.getOneYearFromTodayMidnight(),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export class UserInputService {
|
||||
id: crypto.randomUUID(),
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 1,
|
||||
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||
expiryDate: this.endDate(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +46,50 @@ export class UserInputService {
|
||||
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
||||
readonly computeTriggerSignal = signal(0);
|
||||
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
private static getTodayMidnight(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
private static getOneYearFromTodayMidnight(): Date {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
readonly startDateString = storedSignal<string>(
|
||||
'timeframe.startDate',
|
||||
UserInputService.getTodayMidnight().toISOString(),
|
||||
this.injector,
|
||||
);
|
||||
|
||||
readonly endDateString = storedSignal<string>(
|
||||
'timeframe.endDate',
|
||||
UserInputService.getOneYearFromTodayMidnight().toISOString(),
|
||||
this.injector,
|
||||
);
|
||||
|
||||
readonly startDate = computed(() => new Date(this.startDateString()));
|
||||
readonly endDate = computed(() => new Date(this.endDateString()));
|
||||
|
||||
public setStartDate(date: Date | null): void {
|
||||
if (!date) return;
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
this.startDateString.set(d.toISOString());
|
||||
}
|
||||
|
||||
public setEndDate(date: Date | null): void {
|
||||
if (!date) return;
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
this.endDateString.set(d.toISOString());
|
||||
}
|
||||
|
||||
public requestComputation(): void {
|
||||
this.computeTriggerSignal.update((count) => count + 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Injector, provideZonelessChangeDetection } from '@angular/core';
|
||||
import { storedSignal } from './stored-signal';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('storedSignal', () => {
|
||||
let injector: Injector;
|
||||
const testKey = 'test.key';
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
});
|
||||
injector = TestBed.inject(Injector);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return initial value when no local storage value exists', () => {
|
||||
const signal = storedSignal(testKey, 'initial', injector);
|
||||
expect(signal()).toBe('initial');
|
||||
});
|
||||
|
||||
it('should read from local storage if available', () => {
|
||||
localStorage.setItem(testKey, JSON.stringify('stored'));
|
||||
const signal = storedSignal(testKey, 'initial', injector);
|
||||
expect(signal()).toBe('stored');
|
||||
});
|
||||
|
||||
it('should fallback to initial value and remove key on invalid JSON', () => {
|
||||
localStorage.setItem(testKey, 'invalid-json');
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const signal = storedSignal(testKey, 'initial', injector);
|
||||
|
||||
expect(signal()).toBe('initial');
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
expect(localStorage.getItem(testKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing local storage gracefully', () => {
|
||||
const originalLocalStorage = global.localStorage;
|
||||
Object.defineProperty(global, 'localStorage', { value: undefined, writable: true });
|
||||
|
||||
const signal = storedSignal(testKey, 'initial', injector);
|
||||
expect(signal()).toBe('initial');
|
||||
|
||||
// Restore
|
||||
Object.defineProperty(global, 'localStorage', {
|
||||
value: originalLocalStorage,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"locale": "de-DE",
|
||||
"app": {
|
||||
"title": "vac-optimizer",
|
||||
"description": "Optimieren Sie Ihre Urlaubstage basierend auf Feiertagen und Wochenenden!"
|
||||
},
|
||||
"userInput": {
|
||||
"addVacationDay": "Urlaubstag hinzufügen",
|
||||
"calculate": "Berechnen",
|
||||
"remainingDaysWarning": "{count} nicht platziert:",
|
||||
"remainingDaysManualPlace": "Bitte manuell platzieren.",
|
||||
"chooseDate": "Datum auswählen",
|
||||
"daySuffix": "Tag",
|
||||
"daysSuffix": "Tage",
|
||||
"expires": "abl.",
|
||||
"choosePeriod": "Optimierungszeitraum",
|
||||
"startDate": "Startdatum",
|
||||
"endDate": "Enddatum"
|
||||
},
|
||||
"dayTypes": {
|
||||
"weekend": "Wochenende",
|
||||
"closedDay": "Feiertag",
|
||||
"rtt": "RTT",
|
||||
"cp": "CP",
|
||||
"other": "Andere",
|
||||
"today": "Heute",
|
||||
"any": "Beliebig"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"description": "Einstellungen anpassen",
|
||||
"showWeekNumbers": "Wochennummern anzeigen",
|
||||
"close": "Schließen",
|
||||
"selectLanguage": "Sprache auswählen"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Regeln",
|
||||
"subtitle": "Urlaubsregeln konfigurieren",
|
||||
"priorityInfo": "Regeln werden von oben nach unten angewendet",
|
||||
"ifThenTemplate": "Wenn {type} dann {placement}",
|
||||
"timeWindowTemplate": "Zeitfenster {startDate} bis {endDate}",
|
||||
"editRule": "Regel bearbeiten",
|
||||
"addRule": "Regel hinzufügen",
|
||||
"place": "platzieren",
|
||||
"ruleName": "Regelname",
|
||||
"holidayFalls": "Feiertag fällt auf",
|
||||
"days": "Tage",
|
||||
"type": "Typ",
|
||||
"position": "Position",
|
||||
"getBonus": "Bonus erhalten",
|
||||
"startDate": "Startdatum",
|
||||
"endDate": "Enddatum",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"add": "Hinzufügen",
|
||||
"addRuleButton": "Regel hinzufügen",
|
||||
"newIfThenRule": "Neue Wenn-Dann-Regel",
|
||||
"newTimeWindowRule": "Neue Zeitfenster-Regel",
|
||||
"defaultNewRuleName": "Neue Regel",
|
||||
"defaultNewPeriodName": "Neuer Zeitraum",
|
||||
"daysOfWeek": {
|
||||
"1": "Montag",
|
||||
"2": "Dienstag",
|
||||
"3": "Mittwoch",
|
||||
"4": "Donnerstag",
|
||||
"5": "Freitag",
|
||||
"6": "Samstag",
|
||||
"0": "Sonntag"
|
||||
},
|
||||
"placement": {
|
||||
"before": "davor",
|
||||
"after": "danach",
|
||||
"around": "darum herum",
|
||||
"auto": "automatisch"
|
||||
}
|
||||
},
|
||||
"loading": "Wird geladen...",
|
||||
"weekdaysShort": ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"locale": "en-GB",
|
||||
"app": {
|
||||
"title": "vac-optimizer",
|
||||
"description": "Optimize your vacation days based on holidays and weekends!"
|
||||
},
|
||||
"userInput": {
|
||||
"addVacationDay": "Add a vacation day",
|
||||
"calculate": "Calculate",
|
||||
"remainingDaysWarning": "{count} vacation day{plural} not placed:",
|
||||
"remainingDaysManualPlace": "Please place them manually.",
|
||||
"chooseDate": "Choose a date",
|
||||
"daySuffix": "day",
|
||||
"daysSuffix": "days",
|
||||
"expires": "exp.",
|
||||
"choosePeriod": "Optimization Period",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date"
|
||||
},
|
||||
"dayTypes": {
|
||||
"weekend": "Weekend",
|
||||
"closedDay": "Public Holiday",
|
||||
"rtt": "RTT",
|
||||
"cp": "Paid Leave",
|
||||
"other": "Other",
|
||||
"today": "Today",
|
||||
"any": "Any"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Calendar Settings",
|
||||
"description": "Configure your calendar settings here.",
|
||||
"showWeekNumbers": "Show week numbers",
|
||||
"close": "Close",
|
||||
"selectLanguage": "Language"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Custom Rules",
|
||||
"subtitle": "Manage optimization rules",
|
||||
"priorityInfo": "The order of rules defines their execution priority (from top to bottom). Drag and drop to reorder.",
|
||||
"ifThenTemplate": "If a holiday falls on a <strong>{day}</strong>, place <strong>{placeDays}</strong> day(s) <strong>{placeType}</strong> <strong>{placement}</strong> and get <strong>{rewardDays}</strong> day(s) <strong>{rewardType}</strong> as a bonus.",
|
||||
"timeWindowTemplate": "Place <strong>{placeDays}</strong> day(s) <strong>{placeType}</strong> between <strong>{startDate}</strong> and <strong>{endDate}</strong>.",
|
||||
"editRule": "Edit rule",
|
||||
"addRule": "Add a rule",
|
||||
"place": "Place",
|
||||
"ruleName": "Rule name",
|
||||
"holidayFalls": "Holiday falls on a",
|
||||
"days": "Days",
|
||||
"type": "Type",
|
||||
"position": "Position",
|
||||
"getBonus": "Get as bonus",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"add": "Add",
|
||||
"addRuleButton": "Add Rule",
|
||||
"newIfThenRule": "New \"If ... Then\" Rule",
|
||||
"newTimeWindowRule": "New Imposed Period",
|
||||
"defaultNewRuleName": "New Rule",
|
||||
"defaultNewPeriodName": "New Period",
|
||||
"daysOfWeek": {
|
||||
"1": "Monday",
|
||||
"2": "Tuesday",
|
||||
"3": "Wednesday",
|
||||
"4": "Thursday",
|
||||
"5": "Friday",
|
||||
"6": "Saturday",
|
||||
"0": "Sunday"
|
||||
},
|
||||
"placement": {
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"around": "Around",
|
||||
"auto": "Auto (Best choice)"
|
||||
}
|
||||
},
|
||||
"loading": "Calculating...",
|
||||
"weekdaysShort": ["M", "T", "W", "T", "F", "S", "S"]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"locale": "es-ES",
|
||||
"app": {
|
||||
"title": "vac-optimizer",
|
||||
"description": "¡Optimice sus días de vacaciones según los días festivos y los fines de semana!"
|
||||
},
|
||||
"userInput": {
|
||||
"addVacationDay": "Añadir un día de vacaciones",
|
||||
"calculate": "Calcular",
|
||||
"remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:",
|
||||
"remainingDaysManualPlace": "Por favor, colóquelos manualmente.",
|
||||
"chooseDate": "Elija una fecha",
|
||||
"daySuffix": "día",
|
||||
"daysSuffix": "días",
|
||||
"expires": "exp.",
|
||||
"choosePeriod": "Período de optimización",
|
||||
"startDate": "Fecha de inicio",
|
||||
"endDate": "Fecha de fin"
|
||||
},
|
||||
"dayTypes": {
|
||||
"weekend": "Fin de semana",
|
||||
"closedDay": "Día festivo",
|
||||
"rtt": "RTT",
|
||||
"cp": "Vacaciones pagadas",
|
||||
"other": "Otro",
|
||||
"today": "Hoy",
|
||||
"any": "Cualquier"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes del calendario",
|
||||
"description": "Configure sus ajustes del calendario aquí.",
|
||||
"showWeekNumbers": "Mostrar números de semana",
|
||||
"close": "Cerrar",
|
||||
"selectLanguage": "Idioma"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Reglas personalizadas",
|
||||
"subtitle": "Gestionar reglas de optimización",
|
||||
"priorityInfo": "El orden de las reglas define su prioridad de ejecución (de arriba a abajo). Arrastre y suelte para reorganizar.",
|
||||
"ifThenTemplate": "Si un día festivo cae en <strong>{day}</strong>, colocar <strong>{placeDays}</strong> día(s) <strong>{placeType}</strong> <strong>{placement}</strong> y obtener <strong>{rewardDays}</strong> día(s) <strong>{rewardType}</strong> de bonificación.",
|
||||
"timeWindowTemplate": "Colocar <strong>{placeDays}</strong> día(s) <strong>{placeType}</strong> entre el <strong>{startDate}</strong> y el <strong>{endDate}</strong>.",
|
||||
"editRule": "Editar regla",
|
||||
"addRule": "Añadir una regla",
|
||||
"place": "Colocar",
|
||||
"ruleName": "Nombre de la regla",
|
||||
"holidayFalls": "Día festivo cae en",
|
||||
"days": "Días",
|
||||
"type": "Tipo",
|
||||
"position": "Posición",
|
||||
"getBonus": "Obtener en bonificación",
|
||||
"startDate": "Fecha de inicio",
|
||||
"endDate": "Fecha de fin",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"add": "Añadir",
|
||||
"addRuleButton": "Añadir",
|
||||
"newIfThenRule": "Nueva regla \"Si ... Entonces\"",
|
||||
"newTimeWindowRule": "Nuevo período impuesto",
|
||||
"defaultNewRuleName": "Nueva regla",
|
||||
"defaultNewPeriodName": "Nuevo período",
|
||||
"daysOfWeek": {
|
||||
"1": "Lunes",
|
||||
"2": "Martes",
|
||||
"3": "Miércoles",
|
||||
"4": "Jueves",
|
||||
"5": "Viernes",
|
||||
"6": "Sábado",
|
||||
"0": "Domingo"
|
||||
},
|
||||
"placement": {
|
||||
"before": "Antes",
|
||||
"after": "Después",
|
||||
"around": "Alrededor",
|
||||
"auto": "Auto (Mejor opción)"
|
||||
}
|
||||
},
|
||||
"loading": "Calculando...",
|
||||
"weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"locale": "fr-FR",
|
||||
"app": {
|
||||
"title": "vac-optimizer",
|
||||
"description": "Optimisez vos jours de vacances en fonction des jours fériés et des week-ends !"
|
||||
},
|
||||
"userInput": {
|
||||
"addVacationDay": "Ajouter un jour de vacances",
|
||||
"calculate": "Calculer",
|
||||
"remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:",
|
||||
"remainingDaysManualPlace": "Veuillez les placer manuellement.",
|
||||
"chooseDate": "Choisissez une date",
|
||||
"daySuffix": "jour",
|
||||
"daysSuffix": "jours",
|
||||
"expires": "exp.",
|
||||
"choosePeriod": "Période d'optimisation",
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin"
|
||||
},
|
||||
"dayTypes": {
|
||||
"weekend": "Weekend",
|
||||
"closedDay": "Jour Férié",
|
||||
"rtt": "RTT",
|
||||
"cp": "Congé Payé",
|
||||
"other": "Autre",
|
||||
"today": "Aujourd'hui",
|
||||
"any": "N'importe"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres du calendrier",
|
||||
"description": "Configurez vos paramètres de calendrier ici.",
|
||||
"showWeekNumbers": "Afficher les numéros de semaine",
|
||||
"close": "Fermer",
|
||||
"selectLanguage": "Langue"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Règles Personnalisées",
|
||||
"subtitle": "Gérer les règles d'optimisation",
|
||||
"priorityInfo": "L'ordre des règles définit leur priorité d'exécution (de haut en bas). Glissez-déposez pour réorganiser.",
|
||||
"ifThenTemplate": "Si un jour férié tombe un <strong>{day}</strong>, placer <strong>{placeDays}</strong> jour(s) <strong>{placeType}</strong> <strong>{placement}</strong> et obtenir <strong>{rewardDays}</strong> jour(s) <strong>{rewardType}</strong> en bonus.",
|
||||
"timeWindowTemplate": "Placer <strong>{placeDays}</strong> jour(s) <strong>{placeType}</strong> entre le <strong>{startDate}</strong> et le <strong>{endDate}</strong>.",
|
||||
"editRule": "Modifier la règle",
|
||||
"addRule": "Ajouter une règle",
|
||||
"place": "Placer",
|
||||
"ruleName": "Nom de la règle",
|
||||
"holidayFalls": "Jour férié tombe un",
|
||||
"days": "Jours",
|
||||
"type": "Type",
|
||||
"position": "Position",
|
||||
"getBonus": "Obtenir en bonus",
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"add": "Ajouter",
|
||||
"addRuleButton": "Ajouter",
|
||||
"newIfThenRule": "Nouvelle Règle \"Si ... Alors\"",
|
||||
"newTimeWindowRule": "Nouvelle Période Imposée",
|
||||
"defaultNewRuleName": "Nouvelle Règle",
|
||||
"defaultNewPeriodName": "Nouvelle Période",
|
||||
"daysOfWeek": {
|
||||
"1": "Lundi",
|
||||
"2": "Mardi",
|
||||
"3": "Mercredi",
|
||||
"4": "Jeudi",
|
||||
"5": "Vendredi",
|
||||
"6": "Samedi",
|
||||
"0": "Dimanche"
|
||||
},
|
||||
"placement": {
|
||||
"before": "Avant",
|
||||
"after": "Après",
|
||||
"around": "Autour",
|
||||
"auto": "Auto (Meilleur choix)"
|
||||
}
|
||||
},
|
||||
"loading": "Calcul en cours...",
|
||||
"weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '@angular/compiler';
|
||||
import { vi } from 'vitest';
|
||||
import '@analogjs/vitest-angular/setup-snapshots';
|
||||
import '@analogjs/vitest-angular/setup-serializers';
|
||||
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
||||
|
||||
setupTestBed();
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": ["jasmine"]
|
||||
"types": ["vitest/globals"],
|
||||
"target": "es2022"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference types="vitest" />
|
||||
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
plugins: [angular()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
include: ['**/*.spec.ts'],
|
||||
reporters: ['default'],
|
||||
pool: 'forks',
|
||||
coverage: {
|
||||
provider: 'istanbul',
|
||||
reporter: ['text', 'html', 'cobertura'],
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'import.meta.vitest': mode !== 'production',
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user