Compare commits

...

24 Commits

Author SHA1 Message Date
orangecoding
d7f46d6c68 security update 2026-03-31 13:33:01 +02:00
orangecoding
1c9d7c9d92 storing filter settings in url 2026-03-31 11:46:22 +02:00
orangecoding
bc73de6703 upgrade dependencies 2026-03-31 10:38:50 +02:00
orangecoding
568e0abfa1 fixing login not showing if username or password is incorrect 2026-03-31 09:18:49 +02:00
Stephan
3992a9c81c fix: maplibre-gl runtime errors in production build by isoliting it into a chunk (#288) 2026-03-31 09:14:49 +02:00
Christian Kellner
7346075b9d Add Claude Code GitHub Workflow (#285)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-03-24 08:40:04 +01:00
Christian Kellner
8c039f0026 UI improvements (#283)
* ui-improvements

* improving dashboard and settings

* improve job overview

* improving job card

* improving grid view of listings+

* restructuring settings

* next release version
2026-03-23 13:22:34 +01:00
orangecoding
a1289acf15 fixing some docker issues 2026-03-22 09:41:20 +01:00
orangecoding
8501fc7266 upgrading dependencies 2026-03-21 08:09:15 +01:00
orangecoding
4960846cd7 fixing docker run 2026-03-21 08:08:52 +01:00
orangecoding
3ed17f4442 fixing broken puppeteer providers in docker caused by alpine chromium 146 crashing / switched to debian slim with puppeteer's own chrome for testing / dropped 2-stage build / run as non-root / purge build tools after install, improve docker-test.sh to verify it all works. That's it. ;) 2026-03-20 19:19:20 +01:00
orangecoding
b531a7b77a fixing mcp issue, adding claude example 2026-03-20 13:45:42 +01:00
Adrian Bach
3523057221 feat: add smtp adapter (#279) 2026-03-20 11:37:28 +01:00
orangecoding
77311cf39d next release version 2026-03-17 11:26:39 +01:00
orangecoding
556c0aff35 fixing duplicate migration 2026-03-17 11:26:23 +01:00
orangecoding
c40d275e52 cleanup 2026-03-16 14:48:41 +01:00
orangecoding
cbf2766783 cleanup 2026-03-16 14:48:01 +01:00
orangecoding
1b39e345b6 moving from jest to vitest 2026-03-16 14:26:58 +01:00
orangecoding
6ccbdd8afc upgrading dependencies 2026-03-16 10:41:53 +01:00
orangecoding
2a30c89eb2 improving version banner 2026-03-16 10:37:36 +01:00
orangecoding
4878dc98e3 Merge branch 'master' of github.com:orangecoding/fredy 2026-03-11 15:26:56 +01:00
orangecoding
dc2704997d upgrading dependencies 2026-03-11 15:26:25 +01:00
orangecoding
e107b0fb00 next release version 2026-03-11 15:25:20 +01:00
Promises
6c08675fee Add new properties to real estate translation mappings (#275)
Added few more properties for buying a house
2026-03-11 14:49:21 +01:00
69 changed files with 2728 additions and 2209 deletions

View File

@@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -62,6 +62,7 @@ jobs:
- name: Test container with docker compose - name: Test container with docker compose
run: | run: |
echo "Starting container with docker compose..." echo "Starting container with docker compose..."
mkdir -p ./db ./conf && chmod 777 ./db ./conf
docker compose up --build -d docker compose up --build -d
echo "Waiting for container to be ready (60 seconds for start_period)..." echo "Waiting for container to be ready (60 seconds for start_period)..."
sleep 60 sleep 60

View File

@@ -1,70 +1,55 @@
# ================================ FROM node:22-slim
# Stage 1: Build stage
# ================================
FROM node:22-alpine AS builder
WORKDIR /build ARG TARGETARCH
# Install build dependencies needed for native modules (better-sqlite3) # System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++ # On ARM64 we also install system Chromium (Chrome for Testing has no ARM64 binary)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates fonts-liberation libasound2 \
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
python3 make g++ \
&& if [ "$TARGETARCH" = "arm64" ]; then apt-get install -y --no-install-recommends chromium; fi \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /db /conf /fredy
WORKDIR /fredy
ENV NODE_ENV=production \
IS_DOCKER=true
# Copy package files first for better layer caching
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
# Install all dependencies (including devDependencies for building) # Install dependencies and purge build tools (only needed to compile better-sqlite3)
RUN yarn config set network-timeout 600000 \ RUN yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile && yarn --frozen-lockfile \
&& yarn cache clean
# on arm64 use the system Chromium installed above
RUN if [ "$TARGETARCH" != "arm64" ]; then npx puppeteer browsers install chrome; fi
# Purge build tools now that native modules are compiled
RUN apt-get purge -y python3 make g++ \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy source files needed for build
COPY index.html vite.config.js ./ COPY index.html vite.config.js ./
COPY ui ./ui COPY ui ./ui
COPY lib ./lib COPY lib ./lib
# Build frontend assets
RUN yarn build:frontend RUN yarn build:frontend
# ================================
# Stage 2: Production stage
# ================================
FROM node:22-alpine
WORKDIR /fredy
# Install Chromium and curl (for healthcheck)
# Using Alpine's chromium package which is much smaller
RUN apk add --no-cache chromium curl
ENV NODE_ENV=production \
IS_DOCKER=true \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Install build dependencies for native modules, then remove them after yarn install
COPY package.json yarn.lock ./
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile --production \
&& yarn cache clean \
&& apk del .build-deps
# Copy built frontend from builder stage
COPY --from=builder /build/ui/public ./ui/public
# Copy application source (only what's needed at runtime)
COPY index.js ./ COPY index.js ./
COPY index.html ./
COPY lib ./lib
# Prepare runtime directories and symlinks for data and config RUN ln -s /db /fredy/db \
RUN mkdir -p /db /conf \
&& chown 1000:1000 /db /conf \
&& chmod 777 /db /conf \
&& ln -s /db /fredy/db \
&& ln -s /conf /fredy/conf && ln -s /conf /fredy/conf
EXPOSE 9998 EXPOSE 9998
VOLUME /db VOLUME /db
VOLUME /conf VOLUME /conf
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:9998/ || exit 1
CMD ["node", "index.js"] CMD ["node", "index.js"]

View File

@@ -7,12 +7,72 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
docker rm fredy || true docker rm fredy || true
fi fi
# On Apple Silicon, force linux/amd64 to match production CI and avoid arm64/x86_64
# Chrome mismatch under Rosetta. On native Linux (amd64 or arm64) let Docker pick naturally. That took me fucking 1 hour to figure out.
PLATFORM=""
if [ "$(uname -m)" = "arm64" ] && [ "$(uname -s)" = "Darwin" ]; then
PLATFORM="linux/amd64"
fi
# Build image from local Dockerfile, forcing a fresh build without cache # Build image from local Dockerfile, forcing a fresh build without cache
docker build --no-cache -t fredy:local . if [ -n "$PLATFORM" ]; then
docker build --no-cache --platform "$PLATFORM" -t fredy:local .
else
docker build --no-cache -t fredy:local .
fi
# Run container with volumes and port mapping # Run container with volumes and port mapping
docker run -d --name fredy \ if [ -n "$PLATFORM" ]; then
-v fredy_conf:/conf \ docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
-v fredy_db:/db \ else
-p 9998:9998 \ docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
fredy:local fi
echo "Waiting for app to be ready..."
for i in $(seq 1 30); do
if docker exec fredy curl -sf http://localhost:9998/ > /dev/null 2>&1; then
echo "App is up"
break
fi
if [ "$i" = "30" ]; then
echo "App did not come up in time"
docker logs fredy
exit 1
fi
sleep 2
done
# Verify the DB is readable/writable via the API.
# /api/demo is unauthenticated and reads the settings table — if SQLite is broken this returns an error.
echo "Testing DB via API (/api/demo)..."
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
echo "DB is readable (got demoMode from /api/demo)"
else
echo "DB check failed — unexpected response from /api/demo: $DEMO_RESPONSE"
docker logs fredy
exit 1
fi
# Verify Chrome launches without crashing.
# On amd64: Chrome for Testing lives in the puppeteer cache.
# On arm64: system Chromium is used instead.
echo "Testing Chrome..."
CHROME=$(docker exec fredy find /root/.cache/puppeteer /home -name chrome -type f 2>/dev/null | head -1)
if [ -z "$CHROME" ]; then
CHROME=$(docker exec fredy which chromium 2>/dev/null || true)
fi
if [ -z "$CHROME" ]; then
echo "Chrome/Chromium binary not found"
exit 1
fi
if docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | grep -q "<html"; then
echo "Chrome works"
else
echo "Chrome failed to render a page"
docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | head -20
exit 1
fi
echo ""
echo "All checks passed."

View File

@@ -25,12 +25,15 @@ export default [
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node, ...globals.node,
...globals.mocha, ...globals.jest,
Promise: 'readonly', Promise: 'readonly',
fetch: 'readonly', fetch: 'readonly',
describe: 'readonly', describe: 'readonly',
after: 'readonly', after: 'readonly',
it: 'readonly', it: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
vi: 'readonly',
}, },
}, },
plugins: { react }, plugins: { react },

View File

@@ -20,7 +20,7 @@ import { getDirName } from '../utils.js';
import { demoRouter } from './routes/demoRouter.js'; import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js'; import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js'; import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js'; import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
import { dashboardRouter } from './routes/dashboardRouter.js'; import { dashboardRouter } from './routes/dashboardRouter.js';
import { backupRouter } from './routes/backupRouter.js'; import { backupRouter } from './routes/backupRouter.js';
import { trackingRouter } from './routes/trackingRoute.js'; import { trackingRouter } from './routes/trackingRoute.js';
@@ -28,9 +28,10 @@ import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
const service = restana(); const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public')); const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = (await getSettings()).port || 9998; const PORT = (await getSettings()).port || 9998;
const sessionSecret = await getOrCreateSessionSecret();
service.use(bodyParser.json()); service.use(bodyParser.json());
service.use(cookieSession()); service.use(cookieSession(sessionSecret));
service.use(staticService); service.use(staticService);
service.use('/api/admin', authInterceptor()); service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor()); service.use('/api/jobs', authInterceptor());

View File

@@ -9,6 +9,27 @@ import * as hasher from '../../services/security/hash.js';
import { trackDemoAccessed } from '../../services/tracking/Tracker.js'; import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const MAX_LOGIN_ATTEMPTS = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const loginAttempts = new Map(); // ip -> { count, firstAttempt }
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown';
}
function isRateLimited(ip) {
const now = Date.now();
const record = loginAttempts.get(ip);
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
loginAttempts.set(ip, { count: 1, firstAttempt: now });
return false;
}
record.count++;
return record.count > MAX_LOGIN_ATTEMPTS;
}
const service = restana(); const service = restana();
const loginRouter = service.newRouter(); const loginRouter = service.newRouter();
loginRouter.get('/user', async (req, res) => { loginRouter.get('/user', async (req, res) => {
@@ -25,6 +46,12 @@ loginRouter.get('/user', async (req, res) => {
res.send(); res.send();
}); });
loginRouter.post('/', async (req, res) => { loginRouter.post('/', async (req, res) => {
const ip = getClientIp(req);
if (isRateLimited(ip)) {
logger.error(`Login rate limit exceeded for IP ${ip}`);
res.send(429);
return;
}
const settings = await getSettings(); const settings = await getSettings();
const { username, password } = req.body; const { username, password } = req.body;
const user = userStorage.getUsers(true).find((user) => user.username === username); const user = userStorage.getUsers(true).find((user) => user.username === username);
@@ -38,6 +65,8 @@ loginRouter.post('/', async (req, res) => {
} }
req.session.currentUser = user.id; req.session.currentUser = user.id;
req.session.createdAt = Date.now();
loginAttempts.delete(ip);
userStorage.setLastLoginToNow({ userId: user.id }); userStorage.setLastLoginToNow({ userId: user.id });
res.send(200); res.send(200);
return; return;

View File

@@ -5,12 +5,17 @@
import * as userStorage from '../services/storage/userStorage.js'; import * as userStorage from '../services/storage/userStorage.js';
import cookieSession from 'cookie-session'; import cookieSession from 'cookie-session';
import { nanoid } from 'nanoid'; const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
const unauthorized = (res) => { const unauthorized = (res) => {
return res.send(401); return res.send(401);
}; };
const isUnauthorized = (req) => { const isUnauthorized = (req) => {
return req.session.currentUser == null; if (req.session.currentUser == null) return true;
if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) {
req.session = null;
return true;
}
return false;
}; };
const isAdmin = (req) => { const isAdmin = (req) => {
if (!isUnauthorized(req)) { if (!isUnauthorized(req)) {
@@ -37,12 +42,11 @@ const adminInterceptor = () => {
} }
}; };
}; };
const cookieSession$0 = (userId) => { const cookieSession$0 = (secret) => {
return cookieSession({ return cookieSession({
name: 'fredy-admin-session', name: 'fredy-admin-session',
keys: ['fredy', 'super', 'fancy', 'key', nanoid()], keys: [secret],
userId, maxAge: SESSION_MAX_AGE,
maxAge: 2 * 60 * 60 * 1000, // 2 hours
}); });
}; };
export { cookieSession$0 as cookieSession }; export { cookieSession$0 as cookieSession };

View File

@@ -1,4 +1,4 @@
# Fredy MCP Server # Fredy MCP Server
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports: The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
@@ -126,6 +126,54 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
> **Tip:** Make sure Fredy is running and the database is accessible before starting the MCP server in LM Studio. The stdio transport initializes its own database connection, so Fredy's main process does not need to be running, but the database file must exist and be up-to-date (migrations applied). > **Tip:** Make sure Fredy is running and the database is accessible before starting the MCP server in LM Studio. The stdio transport initializes its own database connection, so Fredy's main process does not need to be running, but the database file must exist and be up-to-date (migrations applied).
### Claude Desktop Configuration
[Claude Desktop](https://claude.ai/download) supports MCP servers natively via its developer settings.
#### Setup
1. Open **Claude Desktop**
2. Go to **Settings → Developer → Edit Config** — this opens the `claude_desktop_config.json` file
3. Add the `fredy` server to the `mcpServers` object:
```json
{
"mcpServers": {
"fredy": {
"command": "/opt/homebrew/opt/node@22/bin/node",
"args": ["/absolute/path/to/fredy/lib/mcp/stdio.js"],
"env": {
"MCP_TOKEN": "fredy_<your-token>"
}
}
}
}
```
Replace `/absolute/path/to/fredy` with the actual path on your machine (e.g. `/Users/you/dev/fredy`).
> **Important:** Claude Desktop launches with a restricted `PATH` and often cannot find `node` by name. Always use the **full absolute path** to the node binary. Find yours by running `which node` in a terminal. Common locations:
> - Homebrew (default): `/opt/homebrew/bin/node`
> - Homebrew (versioned, e.g. node@22): `/opt/homebrew/opt/node@22/bin/node`
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
4. Save the file and **restart Claude Desktop**
5. You should see a hammer icon (🔨) in the chat input — click it to confirm the Fredy tools are listed
#### Usage
Once connected, simply ask Claude about your real estate data:
- *"Show me all my active search jobs"*
- *"List the latest listings from my Berlin apartment search"*
- *"What are the cheapest apartments added this week?"*
Claude will automatically call the appropriate Fredy MCP tools.
> **Note:** Fredy's main web process does not need to be running — the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
---
## Usage with Remote LLM (Streamable HTTP transport) ## Usage with Remote LLM (Streamable HTTP transport)
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at: The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import nodemailer from 'nodemailer';
import path from 'path';
import fs from 'fs';
import Handlebars from 'handlebars';
import { markdown2Html } from '../../services/markdown.js';
import { getDirName, normalizeImageUrl } from '../../utils.js';
const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template);
const mapListings = (serviceName, jobKey, listings) =>
listings.map((l) => {
const image = normalizeImageUrl(l.image);
return {
title: l.title || '',
link: l.link || '',
address: l.address || '',
size: l.size || '',
price: l.price || '',
image,
hasImage: Boolean(image),
serviceName,
jobKey,
};
});
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
(adapter) => adapter.id === config.id,
).fields;
const to = receiver
.trim()
.split(',')
.map((r) => r.trim())
.filter(Boolean);
const transporter = nodemailer.createTransport({
host,
port: Number(port),
secure: secure === 'true',
auth: {
user: username,
pass: password,
},
});
const listings = mapListings(serviceName, jobKey, newListings);
const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
numberOfListings: listings.length,
listings,
});
return transporter.sendMail({
from,
to: to.join(','),
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
html,
});
};
export const config = {
id: 'smtp',
name: 'SMTP',
description: 'Send notifications via any SMTP server using Nodemailer.',
readme: markdown2Html('lib/notification/adapter/smtp.md'),
fields: {
host: {
type: 'text',
label: 'SMTP Host',
description: 'The hostname of the SMTP server (e.g., smtp.gmail.com).',
},
port: {
type: 'text',
label: 'SMTP Port',
description: 'The port of the SMTP server (e.g., 587 for STARTTLS, 465 for SSL).',
},
secure: {
type: 'text',
label: 'Secure (SSL/TLS)',
description: 'Set to "true" for port 465 (SSL). Leave empty or "false" for STARTTLS on port 587.',
},
username: {
type: 'text',
label: 'Username',
description: 'The username for SMTP authentication.',
},
password: {
type: 'text',
label: 'Password',
description: 'The password (or app password) for SMTP authentication.',
},
receiver: {
type: 'text',
label: 'Receiver Email(s)',
description: 'Comma-separated email addresses Fredy will send notifications to.',
},
from: {
type: 'email',
label: 'Sender Email',
description: 'The email address Fredy sends from.',
},
},
};

View File

@@ -0,0 +1,22 @@
### SMTP Adapter
Send notifications through any SMTP server using [Nodemailer](https://nodemailer.com/).
This works with Gmail, Outlook, self-hosted mail servers, or any provider that supports SMTP.
Setup:
- Provide the SMTP host and port of your mail server.
- For **SSL/TLS** (port 465), set Secure to `true`.
- For **STARTTLS** (port 587), leave Secure empty or set it to `false`.
- Enter the username and password for authentication. For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833).
- Set the sender email address (must be allowed by your SMTP server).
Multiple recipients:
- Separate email addresses with commas (e.g., `some@email.com`, `someOther@email.com`).
Common SMTP settings:
- **Gmail** — `smtp.gmail.com`, port 587, secure: false
- **Outlook** — `smtp.office365.com`, port 587, secure: false
- **Yahoo** — `smtp.mail.yahoo.com`, port 465, secure: true

View File

@@ -47,12 +47,17 @@ export async function launchBrowser(url, options) {
removeUserDataDir = true; removeUserDataDir = true;
} }
// On ARM64 Docker, Chrome for Testing has no native binary — use system Chromium instead.
const executablePath =
options?.executablePath ||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: options?.puppeteerHeadless ?? true, headless: options?.puppeteerHeadless ?? true,
args: launchArgs, args: launchArgs,
timeout: options?.puppeteerTimeout || 45_000, timeout: options?.puppeteerTimeout || 45_000,
userDataDir, userDataDir,
executablePath: options?.executablePath, executablePath,
}); });
browser.__fredy_userDataDir = userDataDir; browser.__fredy_userDataDir = userDataDir;

View File

@@ -79,6 +79,8 @@ const PARAM_NAME_MAP = {
price: 'price', price: 'price',
constructionyear: 'constructionyear', constructionyear: 'constructionyear',
apartmenttypes: 'apartmenttypes', apartmenttypes: 'apartmenttypes',
buildingtypes: 'buildingtypes',
ground: 'ground',
pricetype: 'pricetype', pricetype: 'pricetype',
floor: 'floor', floor: 'floor',
geocodes: 'geocodes', geocodes: 'geocodes',
@@ -98,6 +100,7 @@ const EQUIPMENT_MAP = {
guesttoilet: 'guestToilet', guesttoilet: 'guestToilet',
balcony: 'balcony', balcony: 'balcony',
handicappedaccessible: 'handicappedAccessible', handicappedaccessible: 'handicappedAccessible',
lodgerflat: 'lodgerflat',
}; };
const REAL_ESTATE_TYPE = { const REAL_ESTATE_TYPE = {
@@ -107,6 +110,10 @@ const REAL_ESTATE_TYPE = {
'wohnung-kaufen-mit-balkon': 'apartmentbuy', 'wohnung-kaufen-mit-balkon': 'apartmentbuy',
'eigentumswohnung-mit-garten': 'apartmentbuy', 'eigentumswohnung-mit-garten': 'apartmentbuy',
'haus-kaufen': 'housebuy', 'haus-kaufen': 'housebuy',
'haus-mit-keller-kaufen': 'housebuy',
'luxushaus-kaufen': 'housebuy',
'villa-kaufen': 'housebuy',
'neubauhaus-kaufen': 'housebuy',
}; };
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = { const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {

View File

@@ -29,12 +29,12 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { pathToFileURL } from 'url'; import { pathToFileURL, fileURLToPath } from 'url';
import crypto from 'crypto'; import crypto from 'crypto';
import SqliteConnection from '../SqliteConnection.js'; import SqliteConnection from '../SqliteConnection.js';
import logger from '../../logger.js'; import logger from '../../logger.js';
const ROOT = path.resolve('.'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
/** /**
* Absolute path to the migrations directory (lib/services/storage/migrations/sql). * Absolute path to the migrations directory (lib/services/storage/migrations/sql).
* @type {string} * @type {string}

View File

@@ -13,7 +13,10 @@ import crypto from 'crypto';
// Each user gets a permanent, non-expiring secret token used for MCP API authentication. // Each user gets a permanent, non-expiring secret token used for MCP API authentication.
// Tokens are auto-generated for all existing users during this migration. // Tokens are auto-generated for all existing users during this migration.
export function up(db) { export function up(db) {
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`); const columns = db.prepare(`PRAGMA table_info(users)`).all();
if (!columns.some((col) => col.name === 'mcp_token')) {
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
}
// Backfill all existing users that don't have a token yet // Backfill all existing users that don't have a token yet
const users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all(); const users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();

View File

@@ -67,6 +67,19 @@ export async function getSettings() {
return cachedSettingsConfig; return cachedSettingsConfig;
} }
/**
* Get or create a persistent session signing secret.
* Generated once and stored in the settings table under the key 'session_secret'.
* @returns {Promise<string>}
*/
export async function getOrCreateSessionSecret() {
const settings = await getSettings();
if (settings.session_secret) return settings.session_secret;
const secret = nanoid(64);
upsertSettings({ session_secret: secret });
return secret;
}
/** /**
* Upsert settings rows. * Upsert settings rows.
* - Accepts an object map of name -> value, or an entry {name, value}. * - Accepts an object map of name -> value, or an entry {name, value}.

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "20.0.2", "version": "20.1.2",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
@@ -11,8 +11,8 @@
"build:frontend": "vite build", "build:frontend": "vite build",
"format": "prettier --write \"**/*.js\"", "format": "prettier --write \"**/*.js\"",
"format:check": "prettier --check \"**/*.js\"", "format:check": "prettier --check \"**/*.js\"",
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js", "test": "vitest run",
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immobilienDe.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js", "testGH": "vitest run --config vitest.gh.config.js",
"lint": "eslint .", "lint": "eslint .",
"mcp:stdio": "node lib/mcp/stdio.js", "mcp:stdio": "node lib/mcp/stdio.js",
"lint:fix": "yarn lint --fix", "lint:fix": "yarn lint --fix",
@@ -61,30 +61,31 @@
"Firefox ESR" "Firefox ESR"
], ],
"dependencies": { "dependencies": {
"@douyinfe/semi-icons": "^2.92.2", "@douyinfe/semi-icons": "^2.93.0",
"@douyinfe/semi-ui": "2.92.2", "@douyinfe/semi-ui": "2.93.0",
"@douyinfe/semi-ui-19": "^2.92.2", "@douyinfe/semi-ui-19": "^2.93.0",
"@mapbox/mapbox-gl-draw": "^1.5.1", "@mapbox/mapbox-gl-draw": "^1.5.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sendgrid/mail": "8.1.6", "@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.4", "@turf/boolean-point-in-polygon": "^7.3.4",
"@modelcontextprotocol/sdk": "^1.27.1", "@vitejs/plugin-react": "6.0.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.8.0",
"body-parser": "2.2.2", "body-parser": "2.2.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"@turf/boolean-point-in-polygon": "^7.3.4",
"cookie-session": "2.1.1", "cookie-session": "2.1.1",
"handlebars": "4.7.8", "handlebars": "4.7.9",
"lodash": "4.17.23", "lodash": "4.17.23",
"maplibre-gl": "^5.19.0", "maplibre-gl": "^5.21.1",
"nanoid": "5.1.6", "nanoid": "5.1.7",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"node-mailjet": "6.0.11", "node-mailjet": "6.0.11",
"nodemailer": "^8.0.4",
"p-throttle": "^8.1.0", "p-throttle": "^8.1.0",
"package-up": "^5.0.0", "package-up": "^5.0.0",
"puppeteer": "^24.38.0", "puppeteer": "^24.40.0",
"puppeteer-extra": "^3.3.6", "puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2", "puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1", "query-string": "9.3.1",
@@ -92,36 +93,34 @@
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-range-slider-input": "^3.3.2", "react-range-slider-input": "^3.3.2",
"react-router": "7.13.1", "react-router": "7.13.2",
"react-router-dom": "7.13.1", "react-router-dom": "7.13.2",
"resend": "^6.9.3", "resend": "^6.10.0",
"restana": "5.1.0", "restana": "5.1.0",
"semver": "^7.7.4", "semver": "^7.7.4",
"serve-static": "2.2.1", "serve-static": "2.2.1",
"slack": "11.0.2", "slack": "11.0.2",
"vite": "7.3.1", "vite": "8.0.3",
"x-var": "^3.0.1", "x-var": "^3.0.1",
"zustand": "^5.0.11" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.0", "@babel/core": "7.29.0",
"@babel/eslint-parser": "7.28.6", "@babel/eslint-parser": "7.28.6",
"@babel/preset-env": "7.29.0", "@babel/preset-env": "7.29.2",
"@babel/preset-react": "7.28.5", "@babel/preset-react": "7.28.5",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"chai": "6.2.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"eslint": "10.0.3", "eslint": "10.1.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.37.5",
"esmock": "2.7.3",
"globals": "^17.4.0", "globals": "^17.4.0",
"history": "5.3.0", "history": "5.3.0",
"husky": "9.1.7", "husky": "9.1.7",
"less": "4.5.1", "less": "4.6.4",
"lint-staged": "16.3.3", "lint-staged": "16.4.0",
"mocha": "11.7.5",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"prettier": "3.8.1" "prettier": "3.8.1",
"vitest": "^4.1.2"
} }
} }

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'chai'; import { vi, describe, it, expect, beforeEach } from 'vitest';
import esmock from 'esmock';
describe('services/storage/backupRestoreService.js - precheck & filename', () => { describe('services/storage/backupRestoreService.js - precheck & filename', () => {
let svc; let svc;
@@ -14,7 +13,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
beforeEach(async () => { beforeEach(async () => {
calls = { logger: { info: [], warn: [], error: [] } }; calls = { logger: { info: [], warn: [], error: [] } };
// Mock AdmZip with configurable state via globalThis (avoid esmock export name pitfalls) // Mock AdmZip with configurable state via globalThis (avoid mock export name pitfalls)
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null }; globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
setZipState = (s) => { setZipState = (s) => {
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s }; globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s };
@@ -77,67 +76,61 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
const utilsMock = { getPackageVersion: async () => '16.2.0' }; const utilsMock = { getPackageVersion: async () => '16.2.0' };
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js'); vi.resetModules();
const mod = await esmock( vi.doMock('adm-zip', () => admZipMock);
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'), vi.doMock(migratePath, () => migrateMock);
{}, vi.doMock(sqlitePath, () => sqliteMock);
{ vi.doMock(loggerPath, () => loggerMock);
'adm-zip': admZipMock, vi.doMock(utilsPath, () => utilsMock);
[admZipPath]: admZipMock,
[migratePath]: migrateMock,
[sqlitePath]: sqliteMock,
[loggerPath]: loggerMock,
[utilsPath]: utilsMock,
},
);
const mod = await import(path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'));
svc = mod; svc = mod;
}); });
it('precheck: empty upload yields danger', async () => { it('precheck: empty upload yields danger', async () => {
const res = await svc.precheckRestore(Buffer.alloc(0)); const res = await svc.precheckRestore(Buffer.alloc(0));
expect(res.compatible).to.equal(false); expect(res.compatible).toBe(false);
expect(res.severity).to.equal('danger'); expect(res.severity).toBe('danger');
expect(res.message).to.contain('Empty upload'); expect(res.message).toContain('Empty upload');
expect(res.requiredMigration).to.equal(10); expect(res.requiredMigration).toBe(10);
}); });
it('precheck: missing listings.db yields danger', async () => { it('precheck: missing listings.db yields danger', async () => {
setZipState({ hasDb: false, meta: { dbMigration: 9 } }); setZipState({ hasDb: false, meta: { dbMigration: 9 } });
const res = await svc.precheckRestore(Buffer.from('dummy')); const res = await svc.precheckRestore(Buffer.from('dummy'));
expect(res.compatible).to.equal(false); expect(res.compatible).toBe(false);
expect(res.severity).to.equal('danger'); expect(res.severity).toBe('danger');
expect(res.message).to.match(/missing the database file/i); expect(res.message).toMatch(/missing the database file/i);
}); });
it('precheck: older backup is compatible with warning', async () => { it('precheck: older backup is compatible with warning', async () => {
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } }); setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
const res = await svc.precheckRestore(Buffer.from('zip')); const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(true); expect(res.compatible).toBe(true);
expect(res.severity).to.equal('warning'); expect(res.severity).toBe('warning');
expect(res.message).to.match(/automatic migrations/i); expect(res.message).toMatch(/automatic migrations/i);
expect(res.backupMigration).to.equal(5); expect(res.backupMigration).toBe(5);
expect(res.requiredMigration).to.equal(10); expect(res.requiredMigration).toBe(10);
}); });
it('precheck: equal backup is compatible with info', async () => { it('precheck: equal backup is compatible with info', async () => {
setZipState({ hasDb: true, meta: { dbMigration: 10 } }); setZipState({ hasDb: true, meta: { dbMigration: 10 } });
const res = await svc.precheckRestore(Buffer.from('zip')); const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(true); expect(res.compatible).toBe(true);
expect(res.severity).to.equal('info'); expect(res.severity).toBe('info');
}); });
it('precheck: newer backup yields danger', async () => { it('precheck: newer backup yields danger', async () => {
setZipState({ hasDb: true, meta: { dbMigration: 11 } }); setZipState({ hasDb: true, meta: { dbMigration: 11 } });
const res = await svc.precheckRestore(Buffer.from('zip')); const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(false); expect(res.compatible).toBe(false);
expect(res.severity).to.equal('danger'); expect(res.severity).toBe('danger');
}); });
it('buildBackupFileName: matches pattern and includes version', async () => { it('buildBackupFileName: matches pattern and includes version', async () => {
const name = await svc.buildBackupFileName(); const name = await svc.buildBackupFileName();
expect(name).to.match(/^\d{4}-\d{2}-\d{2}-FredyBackup-/); expect(name).toMatch(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
expect(name).to.include('16.2.0'); expect(name).toContain('16.2.0');
expect(name).to.match(/\.zip$/); expect(name).toMatch(/\.zip$/);
}); });
}); });

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'chai'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import esmock from 'esmock';
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules // We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
@@ -85,22 +84,18 @@ describe('db/migrations/migrate.js - runMigrations', () => {
}, },
}; };
// esmock with dependency replacements
const path = await import('node:path'); const path = await import('node:path');
const ROOT = path.resolve('.'); const ROOT = path.resolve('.');
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js'); const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js'); const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
const mod = await esmock(
'../../../db/migrations/migrate.js',
{},
{
fs: fsMock,
crypto: cryptoMock,
[sqlPath]: sqlMock,
[loggerPath]: loggerMock,
},
);
vi.resetModules();
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
vi.doMock(sqlPath, () => ({ default: sqlMock }));
vi.doMock(loggerPath, () => ({ default: loggerMock }));
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
runMigrations = mod.runMigrations; runMigrations = mod.runMigrations;
// remember original exitCode to restore later // remember original exitCode to restore later
@@ -114,9 +109,9 @@ describe('db/migrations/migrate.js - runMigrations', () => {
it('logs and returns when no migration files are found', async () => { it('logs and returns when no migration files are found', async () => {
await runMigrations(); await runMigrations();
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).to.equal(true); expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).toBe(true);
expect(calls.sql.getConnection).to.equal(0); expect(calls.sql.getConnection).toBe(0);
expect(calls.sql.optimize).to.equal(0); expect(calls.sql.optimize).toBe(0);
}); });
it('applies a single new migration inside a transaction and records it', async () => { it('applies a single new migration inside a transaction and records it', async () => {
@@ -165,11 +160,6 @@ describe('db/migrations/migrate.js - runMigrations', () => {
}, },
}; };
// We need to intercept dynamic import by esmock: provide a stub for import(url)
// esmock supports mocking via a virtual module using URL matching, but simpler approach:
// place the file path that migrate.js will compute and make Node import resolve to our stub
// We simulate by mocking url.pathToFileURL is still used, but dynamic import will be handled by esmock when we map the computed path.
const path = await import('node:path'); const path = await import('node:path');
const ROOT = path.resolve('.'); const ROOT = path.resolve('.');
@@ -178,26 +168,22 @@ describe('db/migrations/migrate.js - runMigrations', () => {
// Use global importer hook to bypass dynamic import // Use global importer hook to bypass dynamic import
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule; globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
const mod = await esmock( vi.resetModules();
'../../../db/migrations/migrate.js', vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
{}, vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
{ vi.doMock(sqlPath, () => ({ default: sqlMock }));
fs: fsMock, vi.doMock(loggerPath, () => ({ default: loggerMock }));
crypto: cryptoMock,
[sqlPath]: sqlMock,
[loggerPath]: loggerMock,
},
);
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
runMigrations = mod.runMigrations; runMigrations = mod.runMigrations;
await runMigrations(); await runMigrations();
// Should have started a transaction and inserted into schema_migrations // Should have started a transaction and inserted into schema_migrations
expect(calls.sql.withTransaction.length).to.equal(1); expect(calls.sql.withTransaction.length).toBe(1);
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations')); const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
expect(!!inserted).to.equal(true); expect(!!inserted).toBe(true);
expect(calls.sql.optimize).to.equal(1); expect(calls.sql.optimize).toBe(1);
}); });
it('skips already executed migration with same checksum', async () => { it('skips already executed migration with same checksum', async () => {
@@ -242,24 +228,20 @@ describe('db/migrations/migrate.js - runMigrations', () => {
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} }); globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
const mod = await esmock( vi.resetModules();
'../../../db/migrations/migrate.js', vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
{}, vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
{ vi.doMock(sqlPath, () => ({ default: sqlMock }));
fs: fsMock, vi.doMock(loggerPath, () => ({ default: loggerMock }));
crypto: cryptoMock,
[sqlPath]: sqlMock,
[loggerPath]: loggerMock,
},
);
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
runMigrations = mod.runMigrations; runMigrations = mod.runMigrations;
await runMigrations(); await runMigrations();
// Should not run transaction because it's skipped // Should not run transaction because it's skipped
expect(calls.sql.withTransaction.length).to.equal(0); expect(calls.sql.withTransaction.length).toBe(0);
expect(calls.sql.optimize).to.equal(1); expect(calls.sql.optimize).toBe(1);
}); });
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => { it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
@@ -311,24 +293,20 @@ describe('db/migrations/migrate.js - runMigrations', () => {
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js'); const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js'); const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
const mod = await esmock( vi.resetModules();
'../../../lib/services/storage/migrations/migrate.js', vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
{}, vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
{ vi.doMock(sqlPath, () => ({ default: sqlMock }));
fs: fsMock, vi.doMock(loggerPath, () => ({ default: loggerMock }));
crypto: cryptoMock,
[sqlPath]: sqlMock,
[loggerPath]: loggerMock,
},
);
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
runMigrations = mod.runMigrations; runMigrations = mod.runMigrations;
await runMigrations(); await runMigrations();
expect(process.exitCode).to.equal(1); expect(process.exitCode).toBe(1);
// No insert into schema_migrations should be recorded since transaction failed // No insert into schema_migrations should be recorded since transaction failed
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations')); const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
expect(inserted).to.equal(undefined); expect(inserted).toBe(undefined);
}); });
}); });

View File

@@ -1,4 +0,0 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register('esmock', pathToFileURL('./'));

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'chai'; import { expect } from 'vitest';
import { mockFredy } from './utils.js'; import { mockFredy } from './utils.js';
import * as mockStore from './mocks/mockStore.js'; import * as mockStore from './mocks/mockStore.js';
@@ -34,7 +34,7 @@ describe('Issue reproduction: listings filtered by similarity or area should be
// Might throw NoNewListingsWarning if all are filtered out // Might throw NoNewListingsWarning if all are filtered out
} }
expect(mockStore.deletedIds).to.include('1'); expect(mockStore.deletedIds).toContain('1');
}); });
it('should call deleteListingsById when listings are filtered by area', async () => { it('should call deleteListingsById when listings are filtered by area', async () => {
@@ -84,6 +84,6 @@ describe('Issue reproduction: listings filtered by similarity or area should be
// Might throw NoNewListingsWarning if all are filtered out // Might throw NoNewListingsWarning if all are filtered out
} }
expect(mockStore.deletedIds).to.include('2'); expect(mockStore.deletedIds).toContain('2');
}); });
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js'; import { providerConfig, mockFredy } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/einsAImmobilien.js'; import * as provider from '../../lib/provider/einsAImmobilien.js';
describe('#einsAImmobilien testsuite()', () => { describe('#einsAImmobilien testsuite()', () => {
@@ -23,22 +23,22 @@ describe('#einsAImmobilien testsuite()', () => {
similarityCache, similarityCache,
); );
fredy.execute().then((listings) => { fredy.execute().then((listings) => {
expect(listings).to.be.a('array'); expect(listings).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('einsAImmobilien'); expect(notificationObj.serviceName).toBe('einsAImmobilien');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.size).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).to.be.not.empty; expect(notify.size).not.toBe('');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de'); expect(notify.link).toContain('https://www.1a-immobilienmarkt.de');
}); });
resolve(); resolve();
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js'; import { providerConfig, mockFredy } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/immobilienDe.js'; import * as provider from '../../lib/provider/immobilienDe.js';
describe('#immobilien.de testsuite()', () => { describe('#immobilien.de testsuite()', () => {
@@ -16,24 +16,24 @@ describe('#immobilien.de testsuite()', () => {
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache); const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('immobilienDe'); expect(notificationObj.serviceName).toBe('immobilienDe');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.size).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.price).that.does.include('€'); expect(notify.price).toContain('€');
expect(notify.size).that.does.include('m²'); expect(notify.size).toContain('m²');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.link).that.does.include('https://www.immobilien.de'); expect(notify.link).toContain('https://www.immobilien.de');
expect(notify.address).to.be.not.empty; expect(notify.address).not.toBe('');
}); });
resolve(); resolve();
}); });

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'chai'; import { expect } from 'vitest';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
@@ -16,22 +16,22 @@ describe('#immoscout provider testsuite()', () => {
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache); const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
fredy.execute().then((listings) => { fredy.execute().then((listings) => {
expect(listings).to.be.a('array'); expect(listings).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('immoscout'); expect(notificationObj.serviceName).toBe('immoscout');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.size).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).to.be.not.empty; expect(notify.size).not.toBe('');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.link).that.does.include('https://www.immobilienscout24.de/'); expect(notify.link).toContain('https://www.immobilienscout24.de/');
}); });
resolve(); resolve();
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/immoswp.js'; import * as provider from '../../lib/provider/immoswp.js';
describe('#immoswp testsuite()', () => { describe('#immoswp testsuite()', () => {
@@ -16,21 +16,21 @@ describe('#immoswp testsuite()', () => {
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache); const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('immoswp'); expect(notificationObj.serviceName).toBe('immoswp');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.size).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.price).that.does.include('€'); expect(notify.price).toContain('€');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.link).that.does.include('https://immo.swp.de'); expect(notify.link).toContain('https://immo.swp.de');
}); });
resolve(); resolve();
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/immowelt.js'; import * as provider from '../../lib/provider/immowelt.js';
describe('#immowelt testsuite()', () => { describe('#immowelt testsuite()', () => {
@@ -17,24 +17,24 @@ describe('#immowelt testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache); const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
const listing = await fredy.execute(); const listing = await fredy.execute();
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('immowelt'); expect(notificationObj.serviceName).toBe('immowelt');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') { if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
expect(notify.size).that.does.include('m²'); expect(notify.size).toContain('m²');
} }
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.link).that.does.include('https://www.immowelt.de'); expect(notify.link).toContain('https://www.immowelt.de');
expect(notify.address).to.be.not.empty; expect(notify.address).not.toBe('');
}); });
}); });
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/kleinanzeigen.js'; import * as provider from '../../lib/provider/kleinanzeigen.js';
describe('#kleinanzeigen testsuite()', () => { describe('#kleinanzeigen testsuite()', () => {
@@ -23,20 +23,20 @@ describe('#kleinanzeigen testsuite()', () => {
similarityCache, similarityCache,
); );
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('kleinanzeigen'); expect(notificationObj.serviceName).toBe('kleinanzeigen');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.link).that.does.include('https://www.kleinanzeigen.de'); expect(notify.link).toContain('https://www.kleinanzeigen.de');
expect(notify.address).to.be.not.empty; expect(notify.address).not.toBe('');
}); });
resolve(); resolve();
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/mcMakler.js'; import * as provider from '../../lib/provider/mcMakler.js';
describe('#mcMakler testsuite()', () => { describe('#mcMakler testsuite()', () => {
@@ -17,22 +17,22 @@ describe('#mcMakler testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache); const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const listing = await fredy.execute(); const listing = await fredy.execute();
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('mcMakler'); expect(notificationObj.serviceName).toBe('mcMakler');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.size).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).that.does.include('m²'); expect(notify.size).toContain('m²');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.address).to.be.not.empty; expect(notify.address).not.toBe('');
}); });
}); });
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/neubauKompass.js'; import * as provider from '../../lib/provider/neubauKompass.js';
describe('#neubauKompass testsuite()', () => { describe('#neubauKompass testsuite()', () => {
@@ -23,20 +23,20 @@ describe('#neubauKompass testsuite()', () => {
similarityCache, similarityCache,
); );
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj.serviceName).to.equal('neubauKompass'); expect(notificationObj.serviceName).toBe('neubauKompass');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
expect(notify).to.be.a('object'); expect(notify).toBeTypeOf('object');
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.link).that.does.include('https://www.neubaukompass.de'); expect(notify.link).toContain('https://www.neubaukompass.de');
expect(notify.address).to.be.not.empty; expect(notify.address).not.toBe('');
}); });
resolve(); resolve();
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/ohneMakler.js'; import * as provider from '../../lib/provider/ohneMakler.js';
describe('#ohneMakler testsuite()', () => { describe('#ohneMakler testsuite()', () => {
@@ -17,22 +17,22 @@ describe('#ohneMakler testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache); const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const listing = await fredy.execute(); const listing = await fredy.execute();
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('ohneMakler'); expect(notificationObj.serviceName).toBe('ohneMakler');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.size).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).that.does.include('m²'); expect(notify.size).toContain('m²');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.address).to.be.not.empty; expect(notify.address).not.toBe('');
}); });
}); });
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/regionalimmobilien24.js'; import * as provider from '../../lib/provider/regionalimmobilien24.js';
describe('#regionalimmobilien24 testsuite()', () => { describe('#regionalimmobilien24 testsuite()', () => {
@@ -24,22 +24,22 @@ describe('#regionalimmobilien24 testsuite()', () => {
); );
const listing = await fredy.execute(); const listing = await fredy.execute();
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('regionalimmobilien24'); expect(notificationObj.serviceName).toBe('regionalimmobilien24');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.size).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).that.does.include('m²'); expect(notify.size).toContain('m²');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.address).to.be.not.empty; expect(notify.address).not.toBe('');
}); });
}); });
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/sparkasse.js'; import * as provider from '../../lib/provider/sparkasse.js';
describe('#sparkasse testsuite()', () => { describe('#sparkasse testsuite()', () => {
@@ -17,22 +17,21 @@ describe('#sparkasse testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache); const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const listing = await fredy.execute(); const listing = await fredy.execute();
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('sparkasse'); expect(notificationObj.serviceName).toBe('sparkasse');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).that.does.include('m²'); expect(notify.size).toContain('m²');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.address).to.be.not.empty; expect(notify.address).not.toBe('');
}); });
}); });
}); });

View File

@@ -41,7 +41,7 @@
"enabled": true "enabled": true
}, },
"sparkasse": { "sparkasse": {
"url": "https://immobilien.sparkasse.de/immobilien/treffer?marketingType=buy&objectType=flat&perimeter=10&usageType=residential&zipCityEstateId=62782__Hamburg", "url": "https://immobilien.sparkasse.de/immobilien/treffer?estateTypeGroupingId=403&marketingType=buy&perimeter=10&usageType=residential&zipCityEstateId=51.22422%2F6.78006%2F0__D%C3%BCsseldorf",
"enabled": true "enabled": true
}, },
"wgGesucht": { "wgGesucht": {

View File

@@ -5,7 +5,7 @@
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js'; import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
import assert from 'assert'; import assert from 'assert';
import { expect } from 'chai'; import { expect } from 'vitest';
const fakeWorkingHoursConfig = (from, to) => ({ const fakeWorkingHoursConfig = (from, to) => ({
workingHours: { workingHours: {
@@ -25,19 +25,19 @@ describe('utils', () => {
}); });
describe('#duringWorkingHoursOrNotSet()', () => { describe('#duringWorkingHoursOrNotSet()', () => {
it('should be false', () => { it('should be false', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false; expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).toBe(false);
}); });
it('should be true', () => { it('should be true', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true; expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).toBe(true);
}); });
it('should be true if nothing set', () => { it('should be true if nothing set', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true; expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).toBe(true);
}); });
it('should be true if only to is set', () => { it('should be true if only to is set', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true; expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).toBe(true);
}); });
it('should be true if only from is set', () => { it('should be true if only from is set', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true; expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).toBe(true);
}); });
it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => { it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => {
const cfg = fakeWorkingHoursConfig('05:00', '00:30'); const cfg = fakeWorkingHoursConfig('05:00', '00:30');
@@ -49,9 +49,9 @@ describe('utils', () => {
d.setMilliseconds(0); d.setMilliseconds(0);
return d.getTime(); return d.getTime();
}; };
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).toBe(true); // 23:00 => within window
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).toBe(false); // 01:00 => outside window
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).toBe(true); // 06:00 => within window
}); });
}); });
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js'; import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/wgGesucht.js'; import * as provider from '../../lib/provider/wgGesucht.js';
describe('#wgGesucht testsuite()', () => { describe('#wgGesucht testsuite()', () => {
@@ -16,17 +16,17 @@ describe('#wgGesucht testsuite()', () => {
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache); const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj.serviceName).to.equal('wgGesucht'); expect(notificationObj.serviceName).toBe('wgGesucht');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
expect(notify).to.be.a('object'); expect(notify).toBeTypeOf('object');
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.details).to.be.a('string'); expect(notify.details).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
}); });
resolve(); resolve();
}); });

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js'; import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js'; import { providerConfig, mockFredy } from '../utils.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import * as provider from '../../lib/provider/wohnungsboerse.js'; import * as provider from '../../lib/provider/wohnungsboerse.js';
describe('#wohnungsboerse testsuite()', () => { describe('#wohnungsboerse testsuite()', () => {
@@ -23,22 +23,22 @@ describe('#wohnungsboerse testsuite()', () => {
similarityCache, similarityCache,
); );
fredy.execute().then((listings) => { fredy.execute().then((listings) => {
expect(listings).to.be.a('array'); expect(listings).toBeInstanceOf(Array);
const notificationObj = get(); const notificationObj = get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).to.equal('wohnungsboerse'); expect(notificationObj.serviceName).toBe('wohnungsboerse');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).to.be.a('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.size).to.be.a('string'); expect(notify.size).toBeTypeOf('string');
expect(notify.title).to.be.a('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).to.be.a('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).to.be.a('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).to.be.not.empty; expect(notify.size).not.toBe('');
expect(notify.title).to.be.not.empty; expect(notify.title).not.toBe('');
expect(notify.link).that.does.include('https://www.wohnungsboerse.net'); expect(notify.link).toContain('https://www.wohnungsboerse.net');
}); });
resolve(); resolve();
}); });

View File

@@ -4,7 +4,7 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import { expect } from 'chai'; import { expect } from 'vitest';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import mutator from '../../lib/services/queryStringMutator.js'; import mutator from '../../lib/services/queryStringMutator.js';
import queryString from 'query-string'; import queryString from 'query-string';
@@ -33,8 +33,8 @@ describe('queryStringMutator', () => {
const expectedParams = queryString.parseUrl(test.shouldBecome); const expectedParams = queryString.parseUrl(test.shouldBecome);
const actualParams = queryString.parseUrl(fixedUrl); const actualParams = queryString.parseUrl(fixedUrl);
//check if all new params are existing //check if all new params are existing
expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query)); expect(Object.keys(expectedParams.query)).toEqual(expect.arrayContaining(Object.keys(actualParams.query)));
expect(Object.values(expectedParams.query)).to.include.members(Object.values(actualParams.query)); expect(Object.values(expectedParams.query)).toEqual(expect.arrayContaining(Object.values(actualParams.query)));
} }
}); });
}); });

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { describe, it } from 'mocha'; import { expect } from 'vitest';
import { expect } from 'chai';
import { import {
getPreLaunchConfig, getPreLaunchConfig,
@@ -26,16 +25,16 @@ describe('botPrevention helper', () => {
}; };
const cfg = getPreLaunchConfig(url, options); const cfg = getPreLaunchConfig(url, options);
expect(cfg.acceptLanguage).to.equal('de-DE,de;q=0.9'); expect(cfg.acceptLanguage).toBe('de-DE,de;q=0.9');
expect(cfg.langArg).to.equal('--lang=de-DE'); expect(cfg.langArg).toBe('--lang=de-DE');
expect(cfg.windowSizeArg).to.equal('--window-size=1200,700'); expect(cfg.windowSizeArg).toBe('--window-size=1200,700');
expect(cfg.viewport).to.deep.equal({ width: 1200, height: 700, deviceScaleFactor: 2 }); expect(cfg.viewport).toEqual({ width: 1200, height: 700, deviceScaleFactor: 2 });
expect(cfg.userAgent).to.equal('TestAgent/1.0'); expect(cfg.userAgent).toBe('TestAgent/1.0');
expect(cfg.headers['Accept-Language']).to.equal('de-DE,de;q=0.9'); expect(cfg.headers['Accept-Language']).toBe('de-DE,de;q=0.9');
expect(cfg.headers['User-Agent']).to.equal('TestAgent/1.0'); expect(cfg.headers['User-Agent']).toBe('TestAgent/1.0');
expect(cfg.headers.Referer).to.equal('https://example.com/ref'); expect(cfg.headers.Referer).toBe('https://example.com/ref');
expect(cfg.extraArgs).to.include('--disable-blink-features=AutomationControlled'); expect(cfg.extraArgs).toContain('--disable-blink-features=AutomationControlled');
expect(cfg.extraArgs).to.include('--proxy-bypass-list=<-loopback>'); expect(cfg.extraArgs).toContain('--proxy-bypass-list=<-loopback>');
}); });
it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => { it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => {
@@ -58,15 +57,15 @@ describe('botPrevention helper', () => {
await applyBotPreventionToPage(page, cfg); await applyBotPreventionToPage(page, cfg);
expect(calls[0]).to.deep.equal(['setUserAgent', 'Foo/Bar']); expect(calls[0]).toEqual(['setUserAgent', 'Foo/Bar']);
expect(calls.some((c) => c[0] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).to.equal(true); expect(calls.some((c) => c[0] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).toBe(true);
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).to.equal(true); expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).toBe(true);
const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders'); const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders');
expect(headerCall).to.exist; expect(headerCall).toBeDefined();
expect(headerCall[1]['Accept-Language']).to.equal('en-US,en'); expect(headerCall[1]['Accept-Language']).toBe('en-US,en');
expect(headerCall[1]['User-Agent']).to.equal('Foo/Bar'); expect(headerCall[1]['User-Agent']).toBe('Foo/Bar');
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).to.equal(true); expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).toBe(true);
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).to.equal(true); expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).toBe(true);
}); });
it('applyLanguagePersistence stores languages early', async () => { it('applyLanguagePersistence stores languages early', async () => {
@@ -80,9 +79,9 @@ describe('botPrevention helper', () => {
}); });
await applyLanguagePersistence(page, cfg); await applyLanguagePersistence(page, cfg);
const call = calls[0]; const call = calls[0];
expect(call[0]).to.equal('evaluateOnNewDocument'); expect(call[0]).toBe('evaluateOnNewDocument');
expect(call[1]).to.equal('function'); expect(call[1]).toBe('function');
expect(call[2]).to.equal('de-DE,de'); expect(call[2]).toBe('de-DE,de');
}); });
it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => { it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => {
@@ -98,7 +97,7 @@ describe('botPrevention helper', () => {
viewport: { width: 1200, height: 800 }, viewport: { width: 1200, height: 800 },
}; };
await applyPostNavigationHumanSignals(page, cfg); await applyPostNavigationHumanSignals(page, cfg);
expect(mouseCalls.some((c) => c[0] === 'move')).to.equal(true); expect(mouseCalls.some((c) => c[0] === 'move')).toBe(true);
expect(mouseCalls.some((c) => c[0] === 'wheel')).to.equal(true); expect(mouseCalls.some((c) => c[0] === 'wheel')).toBe(true);
}); });
}); });

View File

@@ -4,7 +4,7 @@
*/ */
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js'; import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
import { expect } from 'chai'; import { expect } from 'vitest';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url))); export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
@@ -18,7 +18,7 @@ describe('#immoscout-mobile URL conversion', () => {
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swapflat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region'; 'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swapflat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
const actualMobileUrl = convertWebToMobile(webUrl); const actualMobileUrl = convertWebToMobile(webUrl);
expect(actualMobileUrl).to.equal(expectedMobileUrl); expect(actualMobileUrl).toBe(expectedMobileUrl);
}); });
// Test URL conversion of web-only SEO path // Test URL conversion of web-only SEO path
@@ -27,27 +27,27 @@ describe('#immoscout-mobile URL conversion', () => {
const converted = convertWebToMobile(webUrl); const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams; const queryParams = new URL(converted).searchParams;
expect(queryParams.get('equipment').split(',')).to.include.members(['garden', 'balcony']); expect(queryParams.get('equipment').split(',')).toEqual(expect.arrayContaining(['garden', 'balcony']));
}); });
// Test URL conversion with unsupported query parameters // Test URL conversion with unsupported query parameters
it('should remove unsupported query parameters', () => { it('should remove unsupported query parameters', () => {
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000'; const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
const converted = convertWebToMobile(webUrl); const converted = convertWebToMobile(webUrl);
expect(converted).that.does.not.include('minimuminternetspeed'); expect(converted).not.toContain('minimuminternetspeed');
}); });
// Test URL conversion with invalid URL // Test URL conversion with invalid URL
it('should throw an error for invalid URL', () => { it('should throw an error for invalid URL', () => {
const invalidUrl = 'invalid-url'; const invalidUrl = 'invalid-url';
expect(() => convertWebToMobile(invalidUrl)).to.throw('Invalid URL: invalid-url'); expect(() => convertWebToMobile(invalidUrl)).toThrow('Invalid URL: invalid-url');
}); });
// Test URL conversion with unexpected path format // Test URL conversion with unexpected path format
it('should throw an error for unexpected path format', () => { it('should throw an error for unexpected path format', () => {
const webUrl = 'https://www.immobilienscout24.de/invalid/path/format'; const webUrl = 'https://www.immobilienscout24.de/invalid/path/format';
expect(() => convertWebToMobile(webUrl)).to.throw('Unexpected path format: /invalid/path/format'); expect(() => convertWebToMobile(webUrl)).toThrow('Unexpected path format: /invalid/path/format');
}); });
it('shouldFindResultsForEveryTestData', async () => { it('shouldFindResultsForEveryTestData', async () => {
@@ -70,14 +70,12 @@ describe('#immoscout-mobile URL conversion', () => {
console.error('Error fetching data from ImmoScout Mobile API:', response.statusText); console.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
} }
expect([null, true]).to.include(response.ok); expect([null, true]).toContain(response.ok);
const responseBody = await response.json(); const responseBody = await response.json();
expect(responseBody.totalResults).to.be.greaterThan(0); expect(responseBody.totalResults).toBeGreaterThan(0);
expect(responseBody.totalResults).to.be.greaterThan(0); expect(responseBody.totalResults).toBeGreaterThan(0);
expect(responseBody.resultListItems.length).to.greaterThan(0); expect(responseBody.resultListItems.length).toBeGreaterThan(0);
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal( expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).toBe(type);
type,
);
} }
}); });
}); });

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'chai'; import { vi, describe, it, expect, beforeEach } from 'vitest';
import esmock from 'esmock';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
describe('services/jobs/jobExecutionService', () => { describe('services/jobs/jobExecutionService', () => {
@@ -22,45 +21,39 @@ describe('services/jobs/jobExecutionService', () => {
const brokerPath = root + '/lib/services/sse/sse-broker.js'; const brokerPath = root + '/lib/services/sse/sse-broker.js';
const utilsPath = root + '/lib/utils.js'; const utilsPath = root + '/lib/utils.js';
const loggerPath = root + '/lib/services/logger.js'; const loggerPath = root + '/lib/services/logger.js';
const notifyPath = root + '/lib/notification/notify.js';
// esmock the service with all its collaborators vi.resetModules();
const mod = await esmock( vi.doMock(busPath, () => ({ bus }));
svcPath, vi.doMock(jobStoragePath, () => ({
{}, getJob: (id) => state.jobsById[id] || null,
{ getJobs: () => state.jobsList.slice(),
[busPath]: { bus }, }));
[jobStoragePath]: { vi.doMock(userStoragePath, () => ({
getJob: (id) => state.jobsById[id] || null, getUsers: () => state.users.slice(),
getJobs: () => state.jobsList.slice(), getUser: (id) => state.users.find((u) => u.id === id) || null,
}, }));
[userStoragePath]: { vi.doMock(brokerPath, () => ({
getUsers: () => state.users.slice(), sendToUsers: (...args) => calls.sent.push(args),
getUser: (id) => state.users.find((u) => u.id === id) || null, }));
}, vi.doMock(utilsPath, () => ({
[brokerPath]: { duringWorkingHoursOrNotSet: () => false,
sendToUsers: (...args) => calls.sent.push(args), }));
}, vi.doMock(loggerPath, () => {
[utilsPath]: { const m = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
duringWorkingHoursOrNotSet: () => false, // avoid startup run return { default: m };
}, });
[loggerPath]: { vi.doMock(notifyPath, () => ({ send: async () => [] }));
debug: () => {}, vi.doMock(root + '/lib/services/jobs/run-state.js', () => ({
info: () => {}, isRunning: () => false,
warn: () => {}, markRunning: (id) => {
error: () => {}, calls.markRunning.push(id);
}, return true;
[root + '/lib/services/jobs/run-state.js']: {
isRunning: () => false,
markRunning: (id) => {
calls.markRunning.push(id);
return true;
},
markFinished: () => {},
},
}, },
); markFinished: () => {},
}));
// call initializer with minimal deps const mod = await import(svcPath);
mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 }); mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 });
return mod; return mod;
} }
@@ -87,13 +80,13 @@ describe('services/jobs/jobExecutionService', () => {
bus.emit('jobs:status', { jobId: 'j1', running: true }); bus.emit('jobs:status', { jobId: 'j1', running: true });
expect(calls.sent.length).to.equal(1, 'sendToUsers should be called once'); expect(calls.sent.length, 'sendToUsers should be called once').toBe(1);
const [recipients, event, data] = calls.sent[0]; const [recipients, event, data] = calls.sent[0];
expect(event).to.equal('jobStatus'); expect(event).toBe('jobStatus');
expect(data).to.deep.equal({ jobId: 'j1', running: true }); expect(data).toEqual({ jobId: 'j1', running: true });
const got = new Set(recipients); const got = new Set(recipients);
const expected = new Set(['owner1', 'u2', 'a1']); const expected = new Set(['owner1', 'u2', 'a1']);
expect(got).to.deep.equal(expected); expect(got).toEqual(expected);
}); });
it('runs all jobs for admin; only own jobs for regular user', async () => { it('runs all jobs for admin; only own jobs for regular user', async () => {
@@ -113,12 +106,12 @@ describe('services/jobs/jobExecutionService', () => {
bus.emit('jobs:runAll', { userId: 'u1' }); bus.emit('jobs:runAll', { userId: 'u1' });
// allow microtasks to flush // allow microtasks to flush
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(new Set(calls.markRunning)).to.deep.equal(new Set(['j1'])); expect(new Set(calls.markRunning)).toEqual(new Set(['j1']));
// Admin: all jobs // Admin: all jobs
calls.markRunning = []; calls.markRunning = [];
bus.emit('jobs:runAll', { userId: 'admin' }); bus.emit('jobs:runAll', { userId: 'admin' });
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(new Set(calls.markRunning)).to.deep.equal(new Set(['j1', 'j2'])); expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2']));
}); });
}); });

View File

@@ -3,18 +3,15 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'chai'; import { vi, describe, it, expect } from 'vitest';
import esmock from 'esmock';
// Helper to create module under test with mocks // Helper to create module under test with mocks
async function loadModuleWith({ entries = [] } = {}) { async function loadModuleWith({ entries = [] } = {}) {
const mod = await esmock('../../lib/services/similarity-check/similarityCache.js', { vi.resetModules();
// Mock the storage to return our controlled entries vi.doMock('../../lib/services/storage/listingsStorage.js', () => ({
'../../lib/services/storage/listingsStorage.js': { getAllEntriesFromListings: () => entries,
getAllEntriesFromListings: () => entries, }));
}, return await import('../../lib/services/similarity-check/similarityCache.js');
});
return mod;
} }
describe('similarityCache', () => { describe('similarityCache', () => {
@@ -27,15 +24,15 @@ describe('similarityCache', () => {
const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries }); const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
// Initially, duplicates should not be detected for new data // Initially, duplicates should not be detected for new data
expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).to.equal(false); expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).toBe(false);
// Now initialize from storage // Now initialize from storage
initSimilarityCache(); initSimilarityCache();
// Exact duplicates should be detected // Exact duplicates should be detected
expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).to.equal(true); expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).toBe(true);
// Ensure falsy-but-valid price 0 is preserved by hashing and detected as duplicate // Ensure falsy-but-valid price 0 is preserved by hashing and detected as duplicate
expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).to.equal(true); expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).toBe(true);
}); });
it('checkAndAddEntry returns false for new entry then true for duplicate on second call', async () => { it('checkAndAddEntry returns false for new entry then true for duplicate on second call', async () => {
@@ -44,8 +41,8 @@ describe('similarityCache', () => {
const first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' }); const first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' }); const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
expect(first).to.equal(false); expect(first).toBe(false);
expect(second).to.equal(true); expect(second).toBe(true);
}); });
it('hashing ignores null/undefined but preserves 0 via behavior', async () => { it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
@@ -53,15 +50,15 @@ describe('similarityCache', () => {
// Add baseline (null address ignored) // Add baseline (null address ignored)
const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null }); const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
expect(add1).to.equal(false); expect(add1).toBe(false);
// Duplicate with undefined address should match // Duplicate with undefined address should match
const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined }); const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined });
expect(dup).to.equal(true); expect(dup).toBe(true);
// Now test that price 0 is preserved (not filtered out) // Now test that price 0 is preserved (not filtered out)
const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' }); const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
expect(addZero).to.equal(false); expect(addZero).toBe(false);
const dupZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' }); const dupZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
expect(dupZero).to.equal(true); expect(dupZero).toBe(true);
}); });
}); });

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'chai'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import esmock from 'esmock';
// We explicitly avoid touching the real filesystem or creating a real DB file. // We explicitly avoid touching the real filesystem or creating a real DB file.
// better-sqlite3 is fully mocked and operates in-memory via our stubs. // better-sqlite3 is fully mocked and operates in-memory via our stubs.
@@ -78,15 +77,10 @@ describe('SqliteConnection', () => {
}; };
}; };
// esmock the module with our stubs vi.resetModules();
SqliteConnection = await esmock( vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
'../../lib/services/storage/SqliteConnection.js', vi.doMock('better-sqlite3', () => ({ default: BetterSqlite3Mock }));
{}, SqliteConnection = (await import('../../lib/services/storage/SqliteConnection.js')).default;
{
fs: fsMock,
'better-sqlite3': { default: BetterSqlite3Mock },
},
);
}); });
afterEach(() => { afterEach(() => {
@@ -98,9 +92,9 @@ describe('SqliteConnection', () => {
const db1 = SqliteConnection.getConnection(); const db1 = SqliteConnection.getConnection();
const db2 = SqliteConnection.getConnection(); const db2 = SqliteConnection.getConnection();
expect(db1).to.equal(db2); expect(db1).toBe(db2);
// journal_mode, synchronous, cache_size, foreign_keys, optimize // journal_mode, synchronous, cache_size, foreign_keys, optimize
expect(calls.db.pragma).to.deep.equal([ expect(calls.db.pragma).toEqual([
'journal_mode = WAL', 'journal_mode = WAL',
'synchronous = NORMAL', 'synchronous = NORMAL',
'cache_size = -64000', 'cache_size = -64000',
@@ -108,21 +102,21 @@ describe('SqliteConnection', () => {
'optimize', 'optimize',
]); ]);
// mkdirSync should not be called because existsSync returned true // mkdirSync should not be called because existsSync returned true
expect(calls.fs.mkdirSync).to.have.length(0); expect(calls.fs.mkdirSync).toHaveLength(0);
}); });
it('executes query and execute helpers', () => { it('executes query and execute helpers', () => {
const rows = SqliteConnection.query('SELECT 1', {}); const rows = SqliteConnection.query('SELECT 1', {});
expect(rows).to.be.an('array'); expect(rows).toBeInstanceOf(Array);
expect(rows[0]).to.deep.equal({ x: 1 }); expect(rows[0]).toEqual({ x: 1 });
const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 }); const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 });
expect(info).to.have.property('changes', 1); expect(info).toHaveProperty('changes', 1);
}); });
it('tableExists uses sqlite_master get()', () => { it('tableExists uses sqlite_master get()', () => {
const exists = SqliteConnection.tableExists('users'); const exists = SqliteConnection.tableExists('users');
expect(exists).to.equal(true); expect(exists).toBe(true);
}); });
it('withTransaction wraps callback', () => { it('withTransaction wraps callback', () => {
@@ -131,17 +125,17 @@ describe('SqliteConnection', () => {
db.prepare('SELECT inside').all({}); db.prepare('SELECT inside').all({});
return 42; return 42;
}); });
expect(result).to.equal(42); expect(result).toBe(42);
expect(calls.db.prepare).to.include('SELECT inside'); expect(calls.db.prepare).toContain('SELECT inside');
}); });
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => { it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
SqliteConnection.optimize(); SqliteConnection.optimize();
// It will use the existing connection and call pragma('optimize') // It will use the existing connection and call pragma('optimize')
expect(calls.db.pragma).to.include('optimize'); expect(calls.db.pragma).toContain('optimize');
SqliteConnection.close(); SqliteConnection.close();
// close increments close counter // close increments close counter
expect(calls.db.close).to.equal(1); expect(calls.db.close).toBe(1);
}); });
}); });

View File

@@ -3,29 +3,24 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { vi } from 'vitest';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import esmock from 'esmock';
import * as mockStore from './mocks/mockStore.js'; import * as mockStore from './mocks/mockStore.js';
import { send } from './mocks/mockNotification.js'; import { send } from './mocks/mockNotification.js';
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url))); export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
vi.mock('../lib/services/storage/listingsStorage.js', () => mockStore);
vi.mock('../lib/services/storage/settingsStorage.js', () => mockStore);
vi.mock('../lib/services/geocoding/geoCodingService.js', () => ({
geocodeAddress: mockStore.getGeocoordinatesByAddress,
}));
vi.mock('../lib/services/storage/jobStorage.js', () => ({
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
}));
vi.mock('../lib/notification/notify.js', () => ({ send }));
export const mockFredy = async () => { export const mockFredy = async () => {
return await esmock('../lib/FredyPipelineExecutioner', { const mod = await import('../lib/FredyPipelineExecutioner.js');
'../lib/services/storage/listingsStorage.js': { return mod.default ?? mod;
...mockStore,
},
'../lib/services/storage/settingsStorage.js': {
...mockStore,
},
'../lib/services/geocoding/geoCodingService.js': {
geocodeAddress: mockStore.getGeocoordinatesByAddress,
},
'../lib/services/storage/jobStorage.js': {
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
},
'../lib/notification/notify.js': {
send,
},
});
}; };

View File

@@ -3,19 +3,19 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'chai'; import { expect } from 'vitest';
import { buildHash } from '../../lib/utils.js'; import { buildHash } from '../../lib/utils.js';
describe('utilsCheck', () => { describe('utilsCheck', () => {
describe('#utilsCheck()', () => { describe('#utilsCheck()', () => {
it('should be null when null input', () => { it('should be null when null input', () => {
expect(buildHash(null)).to.be.null; expect(buildHash(null)).toBeNull();
}); });
it('should be null when null empty', () => { it('should be null when null empty', () => {
expect(buildHash('')).to.be.null; expect(buildHash('')).toBeNull();
}); });
it('should return a value', () => { it('should return a value', () => {
expect(buildHash('bla', '', null)).to.be.a.string; expect(buildHash('bla', '', null)).toBeTypeOf('string');
}); });
}); });
}); });

View File

@@ -8,7 +8,6 @@ import React, { useEffect } from 'react';
import InsufficientPermission from './components/permission/InsufficientPermission'; import InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute'; import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
import GeneralSettings from './views/generalSettings/GeneralSettings'; import GeneralSettings from './views/generalSettings/GeneralSettings';
import UserSettings from './views/userSettings/UserSettings';
import JobMutation from './views/jobs/mutation/JobMutation'; import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator'; import UserMutator from './views/user/mutation/UserMutator';
import { useActions, useSelector } from './services/state/store'; import { useActions, useSelector } from './services/state/store';
@@ -127,15 +126,8 @@ export default function FredyApp() {
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route path="/userSettings" element={<UserSettings />} /> <Route path="/userSettings" element={<Navigate to="/generalSettings" replace />} />
<Route <Route path="/generalSettings" element={<GeneralSettings />} />
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>

View File

@@ -1,12 +1,14 @@
@import './DashboardCardColors.less';
.dashboard-card { .dashboard-card {
width: 100%; width: 100%;
height: 140px; height: 140px;
margin-bottom: 16px; margin-bottom: 16px;
transition: transform 0.2s; transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9); background-color: #181b26;
backdrop-filter: blur(8px); border: 1px solid #232735;
border: 1px solid var(--semi-color-border); border-radius: 10px;
--pulse-color: rgba(255, 255, 255, 0.1); --pulse-color: rgba(255, 255, 255, 0.08);
position: relative; position: relative;
z-index: 1; z-index: 1;
overflow: visible; overflow: visible;
@@ -32,6 +34,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--card-accent, #94a3b8);
}
&__title {
color: var(--semi-color-text-2) !important;
font-size: 12px !important;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
&__content { &__content {
@@ -41,32 +51,51 @@
&__value { &__value {
font-weight: 700; font-weight: 700;
margin-bottom: 4px; margin-bottom: 4px;
color: var(--semi-color-text-0); color: var(--card-accent, var(--semi-color-text-0));
}
&__desc {
color: var(--semi-color-text-3) !important;
} }
&.blue { &.blue {
--pulse-color: var(--semi-color-primary); --pulse-color: @color-blue-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-blue-text;
background-color: @color-blue-bg;
border-color: @color-blue-border;
box-shadow: 0 2px 16px -6px @color-blue-border;
} }
&.orange { &.orange {
--pulse-color: var(--semi-color-warning); --pulse-color: @color-orange-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-orange-text;
background-color: @color-orange-bg;
border-color: @color-orange-border;
box-shadow: 0 2px 16px -6px @color-orange-border;
} }
&.green { &.green {
--pulse-color: var(--semi-color-success); --pulse-color: @color-green-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-green-text;
background-color: @color-green-bg;
border-color: @color-green-border;
box-shadow: 0 2px 16px -6px @color-green-border;
} }
&.purple { &.purple {
--pulse-color: var(--semi-color-info); --pulse-color: @color-purple-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-purple-text;
background-color: @color-purple-bg;
border-color: @color-purple-border;
box-shadow: 0 2px 16px -6px @color-purple-border;
} }
&.gray { &.gray {
--pulse-color: rgba(255, 255, 255, 0.2); --pulse-color: @color-gray-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-gray-text;
background-color: @color-gray-bg;
border-color: @color-gray-border;
box-shadow: 0 2px 16px -6px @color-gray-border;
} }
} }
@@ -75,6 +104,6 @@
opacity: 0.1; opacity: 0.1;
} }
50% { 50% {
opacity: 0.5; opacity: 0.4;
} }
} }

View File

@@ -1,19 +1,19 @@
@color-blue-bg: rgba(0, 123, 255, 0.24); @color-blue-bg: rgba(96, 165, 250, 0.10);
@color-blue-border: #1E40AFFF; @color-blue-border: #3b6ea8;
@color-blue-text: #60a5fa; @color-blue-text: #60a5fa;
@color-orange-bg: rgba(250, 91, 5, 0.12); @color-orange-bg: rgba(251, 146, 60, 0.10);
@color-orange-border: #992f0c; @color-orange-border: #c2622a;
@color-orange-text: #FB923CFF; @color-orange-text: #fb923c;
@color-green-bg: rgba(38, 250, 5, 0.12); @color-green-bg: rgba(52, 211, 153, 0.10);
@color-green-border: #278832; @color-green-border: #2a8a61;
@color-green-text: #33f308; @color-green-text: #34d399;
@color-purple-bg: rgba(91, 3, 218, 0.38); @color-purple-bg: rgba(167, 139, 250, 0.10);
@color-purple-border: #7500c3; @color-purple-border: #6d4fc2;
@color-purple-text: #b15fff; @color-purple-text: #a78bfa;
@color-gray-bg: rgba(110, 110, 110, 0.38); @color-gray-bg: rgba(148, 163, 184, 0.10);
@color-gray-border: #807f7f; @color-gray-border: #323a47;
@color-gray-text: #bab9b9; @color-gray-text: #94a3b8;

View File

@@ -9,7 +9,6 @@ import {
Col, Col,
Row, Row,
Button, Button,
Space,
Typography, Typography,
Divider, Divider,
Switch, Switch,
@@ -20,6 +19,8 @@ import {
Pagination, Pagination,
Toast, Toast,
Empty, Empty,
Radio,
RadioGroup,
} from '@douyinfe/semi-ui-19'; } from '@douyinfe/semi-ui-19';
import { import {
IconAlertTriangle, IconAlertTriangle,
@@ -31,8 +32,10 @@ import {
IconBriefcase, IconBriefcase,
IconBell, IconBell,
IconSearch, IconSearch,
IconFilter,
IconPlusCircle, IconPlusCircle,
IconArrowUp,
IconArrowDown,
IconHome,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import ListingDeletionModal from '../../ListingDeletionModal.jsx';
@@ -59,8 +62,6 @@ const JobGrid = () => {
const [sortDir, setSortDir] = useState('asc'); const [sortDir, setSortDir] = useState('asc');
const [freeTextFilter, setFreeTextFilter] = useState(null); const [freeTextFilter, setFreeTextFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null); const [activityFilter, setActivityFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId } const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
@@ -200,73 +201,45 @@ const JobGrid = () => {
return ( return (
<div className="jobGrid"> <div className="jobGrid">
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium"> <div className="jobGrid__topbar">
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}> <Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job New Job
</Button> </Button>
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Button
icon={<IconFilter />}
style={{ marginLeft: '8px' }}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Space>
{showFilterBar && ( <Input
<div className="jobGrid__toolbar"> className="jobGrid__topbar__search"
<Space wrap style={{ marginBottom: '1rem' }}> prefix={<IconSearch />}
<div className="jobGrid__toolbar__card"> showClear
<div> placeholder="Search"
<Text strong>Filter by:</Text> onChange={handleFilterChange}
</div> />
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
style={{ width: 140 }}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="jobGrid__toolbar__card">
<div>
<Text strong>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 160 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
</Select>
<Select <RadioGroup
placeholder="Direction" type="button"
style={{ width: 120 }} buttonSize="middle"
value={sortDir} value={activityFilter === null ? 'all' : String(activityFilter)}
onChange={(val) => setSortDir(val)} onChange={(e) => {
> const v = e.target.value;
<Select.Option value="asc">Ascending</Select.Option> setActivityFilter(v === 'all' ? null : v === 'true');
<Select.Option value="desc">Descending</Select.Option> }}
</Select> >
</div> <Radio value="all">All</Radio>
</div> <Radio value="true">Active</Radio>
</Space> <Radio value="false">Inactive</Radio>
</div> </RadioGroup>
)}
<Select prefix="Sort by" style={{ width: 200 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/>
</div>
{(jobsData?.result || []).length === 0 && ( {(jobsData?.result || []).length === 0 && (
<Empty <Empty
@@ -278,78 +251,70 @@ const JobGrid = () => {
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{(jobsData?.result || []).map((job) => ( {(jobsData?.result || []).map((job) => (
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}> <Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
<Card <Card className="jobGrid__card" bodyStyle={{ padding: '16px' }}>
className="jobGrid__card" <div className="jobGrid__card__header">
bodyStyle={{ padding: '16px' }} <div className="jobGrid__card__name">
title={ <span className={`jobGrid__card__dot${job.enabled ? ' jobGrid__card__dot--active' : ''}`} />
<div className="jobGrid__header">
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title"> <Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
{job.name} {job.name}
</Title> </Title>
<div style={{ display: 'flex', alignItems: 'center' }}> </div>
{job.isOnlyShared && ( <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<Popover {job.isOnlyShared && (
content={getPopoverContent( <Popover
'This job has been shared with you by another user, therefor it is read-only.', content={getPopoverContent(
)} 'This job has been shared with you by another user, therefor it is read-only.',
> )}
<div> >
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} /> <div>
</div> <IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</Popover> </div>
)} </Popover>
</div> )}
{job.running && ( {job.running && (
<Tag color="green" variant="light" size="small"> <Tag color="green" variant="light" size="small">
RUNNING RUNNING
</Tag> </Tag>
)} )}
</div> </div>
} </div>
>
<div className="jobGrid__content">
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Is active:
</Text>
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
style={{ marginLeft: 'auto' }}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Listings:
</Text>
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
{job.numberOfFoundListings || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBriefcase />} size="small">
Providers:
</Text>
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
{job.provider.length || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBell />} size="small">
Adapters:
</Text>
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
{job.notificationAdapter.length || 0}
</Tag>
</div>
</Space>
<Divider margin="12px" /> <div className="jobGrid__card__stats">
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
<span className="jobGrid__card__stat__label">
<IconHome size="small" /> Listings
</span>
</div>
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
<span className="jobGrid__card__stat__number">{job.provider.length || 0}</span>
<span className="jobGrid__card__stat__label">
<IconBriefcase size="small" /> Providers
</span>
</div>
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
<span className="jobGrid__card__stat__number">{job.notificationAdapter.length || 0}</span>
<span className="jobGrid__card__stat__label">
<IconBell size="small" /> Adapters
</span>
</div>
</div>
<Divider margin="12px" />
<div className="jobGrid__card__footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
<Text type="secondary" size="small">
Active
</Text>
</div>
<div className="jobGrid__actions"> <div className="jobGrid__actions">
<Popover content={getPopoverContent('Run Job')}> <Popover content={getPopoverContent('Run Job')}>
<div> <div>

View File

@@ -1,3 +1,5 @@
@import '../../cards/DashboardCardColors.less';
.jobGrid { .jobGrid {
&__card { &__card {
height: 100%; height: 100%;
@@ -12,55 +14,137 @@
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%); box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
background-color: rgba(36, 36, 36, 1); background-color: rgba(36, 36, 36, 1);
} }
}
&__searchbar { &__header {
display: flex; display: flex;
gap: .5rem; align-items: flex-start;
align-items: center; justify-content: space-between;
justify-content: space-between; gap: 8px;
margin-bottom: 1rem; margin-bottom: 16px;
} }
&__toolbar { &__name {
&__card { display: flex;
border-radius: var(--semi-border-radius-medium); align-items: center;
gap: 8px;
min-width: 0;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--semi-color-text-3);
&--active {
background-color: #21aa21;
}
}
&__stats {
display: flex;
gap: 8px;
}
&__stat {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .3rem; align-items: center;
background: rgba(36, 36, 36, 0.9); background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(8px); border: 1px solid transparent;
padding: 0.5rem; border-radius: var(--semi-border-radius-small);
border: 1px solid var(--semi-color-border); padding: 10px 4px 8px;
&__number {
font-size: 22px;
font-weight: 600;
color: var(--semi-color-text-0);
line-height: 1.2;
}
&__label {
font-size: 11px;
color: var(--semi-color-text-3);
display: flex;
align-items: center;
gap: 3px;
margin-top: 4px;
}
&--blue {
background: @color-blue-bg;
border-color: @color-blue-border;
.jobGrid__card__stat__number { color: @color-blue-text; }
.jobGrid__card__stat__label { color: @color-blue-text; opacity: 0.7; }
}
&--orange {
background: @color-orange-bg;
border-color: @color-orange-border;
.jobGrid__card__stat__number { color: @color-orange-text; }
.jobGrid__card__stat__label { color: @color-orange-text; opacity: 0.7; }
}
&--purple {
background: @color-purple-bg;
border-color: @color-purple-border;
.jobGrid__card__stat__number { color: @color-purple-text; }
.jobGrid__card__stat__label { color: @color-purple-text; opacity: 0.7; }
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
} }
} }
&__header { &__topbar {
display: flex; display: flex;
flex-wrap: nowrap;
align-items: center; align-items: center;
justify-content: space-between; gap: 8px;
margin-bottom: 16px;
.jobGrid__topbar__search {
flex: 1;
min-width: 0;
}
@media (max-width: 768px) {
flex-wrap: wrap;
.semi-button:first-child {
flex-shrink: 0;
}
.jobGrid__topbar__search {
flex: 1;
min-width: 160px;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
} }
&__title { &__title {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
&__infoItem {
display: flex;
align-items: center;
width: 100%;
.semi-typography {
display: flex;
align-items: center;
gap: 4px;
}
}
&__actions { &__actions {
display: flex; display: flex;
justify-content: space-between; gap: 6px;
gap: 8px;
} }
&__pagination { &__pagination {

View File

@@ -4,21 +4,27 @@
*/ */
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import {
useSearchParamState,
parseNumber,
parseString,
parseNullableBoolean,
} from '../../../hooks/useSearchParamState.js';
import { import {
Card, Card,
Col, Col,
Row, Row,
Image, Image,
Button, Button,
Space,
Typography, Typography,
Pagination, Pagination,
Toast, Toast,
Divider, Divider,
Input, Input,
Select, Select,
Popover,
Empty, Empty,
Radio,
RadioGroup,
} from '@douyinfe/semi-ui-19'; } from '@douyinfe/semi-ui-19';
import { import {
IconBriefcase, IconBriefcase,
@@ -30,11 +36,12 @@ import {
IconStar, IconStar,
IconStarStroked, IconStarStroked,
IconSearch, IconSearch,
IconFilter,
IconActivity, IconActivity,
IconEyeOpened, IconEyeOpened,
IconArrowUp,
IconArrowDown,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import ListingDeletionModal from '../../ListingDeletionModal.jsx';
import no_image from '../../../assets/no_image.jpg'; import no_image from '../../../assets/no_image.jpg';
import * as timeService from '../../../services/time/timeService.js'; import * as timeService from '../../../services/time/timeService.js';
@@ -53,19 +60,18 @@ const ListingsGrid = () => {
const jobs = useSelector((state) => state.jobsData.jobs); const jobs = useSelector((state) => state.jobsData.jobs);
const actions = useActions(); const actions = useActions();
const navigate = useNavigate(); const navigate = useNavigate();
const sp = useSearchParams();
const [page, setPage] = useState(1); const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
const pageSize = 40; const pageSize = 40;
const [sortField, setSortField] = useState('created_at'); const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
const [sortDir, setSortDir] = useState('desc'); const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
const [freeTextFilter, setFreeTextFilter] = useState(null); const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
const [watchListFilter, setWatchListFilter] = useState(null); const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
const [jobNameFilter, setJobNameFilter] = useState(null); const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
const [activityFilter, setActivityFilter] = useState(null); const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useState(null); const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [showFilterBar, setShowFilterBar] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null); const [listingToDelete, setListingToDelete] = useState(null);
@@ -84,7 +90,7 @@ const ListingsGrid = () => {
loadData(); loadData();
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]); }, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []); const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value || null), 500), []);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -129,107 +135,85 @@ const ListingsGrid = () => {
return ( return (
<div className="listingsGrid"> <div className="listingsGrid">
<div className="listingsGrid__searchbar"> <div className="listingsGrid__topbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} /> <Input
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}> className="listingsGrid__topbar__search"
<div> prefix={<IconSearch />}
<Button showClear
icon={<IconFilter />} placeholder="Search"
onClick={() => { defaultValue={freeTextFilter ?? ''}
setShowFilterBar(!showFilterBar); onChange={handleFilterChange}
}} />
/>
</div> <RadioGroup
</Popover> type="button"
buttonSize="middle"
value={activityFilter === null ? 'all' : String(activityFilter)}
onChange={(e) => {
const v = e.target.value;
setActivityFilter(v === 'all' ? null : v === 'true');
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Active</Radio>
<Radio value="false">Inactive</Radio>
</RadioGroup>
<RadioGroup
type="button"
buttonSize="middle"
value={watchListFilter === null ? 'all' : String(watchListFilter)}
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Watched</Radio>
<Radio value="false">Unwatched</Radio>
</RadioGroup>
<Select
placeholder="Provider"
showClear
onChange={(val) => setProviderFilter(val)}
value={providerFilter}
style={{ width: 130 }}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job"
showClear
onChange={(val) => setJobNameFilter(val)}
value={jobNameFilter}
style={{ width: 130 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/>
</div> </div>
{showFilterBar && (
<div className="listingsGrid__toolbar">
<Space wrap style={{ marginBottom: '1rem' }}>
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Filter by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
<Select
placeholder="Watchlist"
showClear
onChange={(val) => setWatchListFilter(val)}
value={watchListFilter}
>
<Select.Option value={true}>Watched</Select.Option>
<Select.Option value={false}>Not Watched</Select.Option>
</Select>
<Select
placeholder="Provider"
showClear
onChange={(val) => setProviderFilter(val)}
value={providerFilter}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job Name"
showClear
onChange={(val) => setJobNameFilter(val)}
value={jobNameFilter}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 140 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Select
placeholder="Direction"
style={{ width: 120 }}
value={sortDir}
onChange={(val) => setSortDir(val)}
>
<Select.Option value="asc">Ascending</Select.Option>
<Select.Option value="desc">Descending</Select.Option>
</Select>
</div>
</div>
</Space>
</div>
)}
{(listingsData?.result || []).length === 0 && ( {(listingsData?.result || []).length === 0 && (
<Empty <Empty
@@ -240,7 +224,7 @@ const ListingsGrid = () => {
)} )}
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{(listingsData?.result || []).map((item) => ( {(listingsData?.result || []).map((item) => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}> <Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
<Card <Card
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`} className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -280,10 +264,11 @@ const ListingsGrid = () => {
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title"> <Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)} {cap(item.title)}
</Text> </Text>
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}> <div className="listingsGrid__price">
<Text type="secondary" icon={<IconCart />} size="small"> <IconCart size="small" />
{item.price} {item.price}
</Text> </div>
<div className="listingsGrid__meta">
<Text <Text
type="secondary" type="secondary"
icon={<IconMapPin />} icon={<IconMapPin />}
@@ -305,18 +290,17 @@ const ListingsGrid = () => {
</Text> </Text>
) : ( ) : (
<Text type="tertiary" size="small" icon={<IconActivity />}> <Text type="tertiary" size="small" icon={<IconActivity />}>
Distance cannot be calculated, provide an address Distance cannot be calculated
</Text> </Text>
)} )}
</Space> </div>
<Divider margin=".6rem" /> <Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div className="listingsGrid__actions">
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}> <div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
<a href={item.link} target="_blank" rel="noopener noreferrer"> <a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink /> <IconLink />
</a> </a>
</div> </div>
<Button <Button
type="secondary" type="secondary"
size="small" size="small"
@@ -324,7 +308,6 @@ const ListingsGrid = () => {
onClick={() => navigate(`/listings/listing/${item.id}`)} onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />} icon={<IconEyeOpened />}
/> />
<Button <Button
title="Remove" title="Remove"
type="danger" type="danger"

View File

@@ -1,3 +1,5 @@
@import '../../cards/DashboardCardColors.less';
.listingsGrid { .listingsGrid {
&__imageContainer { &__imageContainer {
position: relative; position: relative;
@@ -5,12 +7,34 @@
overflow: hidden; overflow: hidden;
} }
&__searchbar { &__topbar {
display: flex; display: flex;
gap: .5rem; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; gap: 8px;
margin-bottom: 1rem; margin-bottom: 16px;
.listingsGrid__topbar__search {
flex: 1;
min-width: 200px;
}
@media (max-width: 768px) {
.listingsGrid__topbar__search {
width: 100%;
flex: unset;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
} }
&__watchButton { &__watchButton {
@@ -93,17 +117,27 @@
justify-content: center; justify-content: center;
} }
&__toolbar { &__price {
&__card { font-size: 18px;
border-radius: var(--semi-border-radius-medium); font-weight: 700;
display: flex; color: @color-green-text;
flex-direction: column; display: flex;
gap: .3rem; align-items: center;
background: rgba(36, 36, 36, 0.9); gap: 5px;
backdrop-filter: blur(8px); margin: 8px 0 6px;
padding: 0.5rem; }
border: 1px solid var(--semi-color-border);
} &__meta {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
}
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
} }
&__setupButton { &__setupButton {

View File

@@ -42,24 +42,21 @@ export default function Navigation({ isAdmin }) {
]; ];
if (isAdmin) { if (isAdmin) {
const settingsItems = [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/userSettings', text: 'User Specific Settings' },
{ itemKey: '/generalSettings', text: 'General Settings' },
];
items.push({ items.push({
itemKey: 'settings', itemKey: 'settings',
text: 'Settings', text: 'Settings',
icon: <IconSetting />, icon: <IconSetting />,
items: settingsItems, items: [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/generalSettings', text: 'Settings' },
],
}); });
} else { } else {
items.push({ items.push({
itemKey: 'settings', itemKey: 'settings',
text: 'Settings', text: 'Settings',
icon: <IconSetting />, icon: <IconSetting />,
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }], items: [{ itemKey: '/generalSettings', text: 'Settings' }],
}); });
} }

View File

@@ -3,5 +3,5 @@
border-radius: .9rem !important; border-radius: .9rem !important;
color: rgba(var(--semi-grey-8), 1); color: rgba(var(--semi-grey-8), 1);
background: rgb(53, 54, 60); background: rgb(53, 54, 60);
margin: 2rem; margin: 0 0 1rem 0;
} }

View File

@@ -3,36 +3,86 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19'; import { useState } from 'react';
import { Banner, Button, Modal, Tag, Space, Typography, Descriptions, MarkdownRender } from '@douyinfe/semi-ui-19';
import { IconAlertCircle, IconArrowRight } from '@douyinfe/semi-icons';
import { useSelector } from '../../services/state/store.js'; import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui-19';
import './VersionBanner.less'; import './VersionBanner.less';
const { Text } = Typography;
export default function VersionBanner() { export default function VersionBanner() {
const [modalVisible, setModalVisible] = useState(false);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate); const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
return ( return (
<Collapse> <>
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner"> <Banner
<div className="versionBanner__content"> className="versionBanner"
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p> type="warning"
<Descriptions row size="small"> bordered
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item> closeIcon={null}
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item> description={
<Descriptions.Item itemKey="Github Release"> <div className="versionBanner__bar">
<a href={versionUpdate.url} target="_blank" rel="noreferrer"> <Space spacing={8} align="center">
{versionUpdate.url} <IconAlertCircle size="small" />
</a>{' '} <Text strong size="small">
</Descriptions.Item> New version available
</Descriptions> </Text>
<p> <Tag color="amber" size="small" shape="circle">
<b> {versionUpdate.version}
<small>Release Notes</small> </Tag>
</b> <Text type="tertiary" size="small">
</p> Current: {versionUpdate.localFredyVersion}
</Text>
</Space>
<Button
theme="borderless"
size="small"
icon={<IconArrowRight />}
iconPosition="right"
onClick={() => setModalVisible(true)}
>
Release notes
</Button>
</div>
}
/>
<Modal
title={
<Space spacing={8} align="center">
<Text strong>Fredy {versionUpdate.version}</Text>
<Tag color="amber" size="small">
New
</Tag>
</Space>
}
visible={modalVisible}
onCancel={() => setModalVisible(false)}
width={640}
footer={
<Space>
<Button onClick={() => setModalVisible(false)}>Close</Button>
<Button
type="primary"
icon={<IconArrowRight />}
iconPosition="right"
onClick={() => window.open(versionUpdate.url, '_blank')}
>
View on GitHub
</Button>
</Space>
}
>
<Descriptions row size="small" className="versionBanner__details">
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
</Descriptions>
<div className="versionBanner__notes">
<MarkdownRender raw={versionUpdate.body} /> <MarkdownRender raw={versionUpdate.body} />
</div> </div>
</Collapse.Panel> </Modal>
</Collapse> </>
); );
} }

View File

@@ -1,7 +1,24 @@
.versionBanner { .versionBanner {
background: rgba(var(--semi-teal-1), 1); margin-bottom: 0 !important;
&__content { .semi-banner-body {
overflow: auto; padding: 6px 16px;
} }
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
&__details {
margin-bottom: 16px;
}
&__notes {
max-height: 400px;
overflow-y: auto;
padding-right: 4px;
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useCallback } from 'react';
// Preset parsers for common types
export const parseString = {
parse: (v) => v,
stringify: (v) => v,
};
export const parseNumber = {
parse: (v) => Number(v),
stringify: (v) => String(v),
};
export const parseBoolean = {
parse: (v) => v === 'true',
stringify: (v) => String(v),
};
// For state that is null | true | false
export const parseNullableBoolean = {
parse: (v) => (v === 'true' ? true : v === 'false' ? false : null),
stringify: (v) => (v === null ? null : String(v)),
};
/**
* Drop-in replacement for useState that syncs with URL search params.
* Uses replace: true so filter changes don't add browser history entries.
*
* Requires a shared [searchParams, setSearchParams] pair from a single
* useSearchParams() call in the component. This ensures multiple hooks
* in the same component don't overwrite each other's params.
*
* @param {[URLSearchParams, Function]} searchParamsPair - from useSearchParams()
* @param {string} key - URL search param key
* @param {*} defaultValue - value when param is absent
* @param {{ parse: (s: string) => *, stringify: (v: *) => string|null }} [options]
*/
export function useSearchParamState([searchParams, setSearchParams], key, defaultValue, options = {}) {
const { parse = (v) => v, stringify = (v) => String(v) } = options;
const rawValue = searchParams.get(key);
const value = rawValue !== null ? parse(rawValue) : defaultValue;
const setValue = useCallback(
(newValue) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
const serialized = stringify(newValue);
if (newValue === defaultValue || newValue === null || newValue === undefined || serialized === null) {
next.delete(key);
} else {
next.set(key, serialized);
}
return next;
},
{ replace: true },
);
},
[key, defaultValue, stringify],
);
return [value, setValue];
}

View File

@@ -4,7 +4,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19'; import { Button, Col, Row, Toast, Typography } from '@douyinfe/semi-ui-19';
import { import {
IconTerminal, IconTerminal,
IconStar, IconStar,
@@ -22,7 +22,6 @@ import KpiCard from '../../components/cards/KpiCard.jsx';
import PieChartCard from '../../components/cards/PieChartCard.jsx'; import PieChartCard from '../../components/cards/PieChartCard.jsx';
import './Dashboard.less'; import './Dashboard.less';
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
import { xhrPost } from '../../services/xhr.js'; import { xhrPost } from '../../services/xhr.js';
import { format } from '../../services/time/timeService.js'; import { format } from '../../services/time/timeService.js';
@@ -35,129 +34,119 @@ export default function Dashboard() {
const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 }; const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
const pieData = dashboard?.pie || []; const pieData = dashboard?.pie || [];
const { Text } = Typography;
return ( return (
<div className="dashboard"> <div className="dashboard">
<Text className="dashboard__section-label">General</Text>
<Row gutter={[16, 16]} className="dashboard__row"> <Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}> <Col xs={24} sm={12} md={12} lg={6} xl={6}>
<SegmentPart name="General" Icon={IconTerminal}> <KpiCard
<Row gutter={[16, 16]} className="dashboard__row"> title="Search Interval"
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> value={`${dashboard?.general?.interval} min`}
<KpiCard icon={<IconClock />}
title="Search Interval" description="Time interval for job execution"
value={`${dashboard?.general?.interval} min`} />
icon={<IconClock />}
description="Time interval for job execution"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Last Search"
valueFontSize="14px"
value={
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
? '---'
: format(dashboard?.general?.lastRun)
}
icon={<IconDoubleChevronLeft />}
description="Last execution timestamp"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Next Search"
value={
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
? '---'
: format(dashboard?.general?.nextRun)
}
valueFontSize="14px"
icon={<IconDoubleChevronRight />}
description="Next execution timestamp"
/>
</Col>
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
<Button
size="small"
style={{ marginTop: '.2rem' }}
icon={<IconPlayCircle />}
aria-label="Start now"
onClick={async () => {
try {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
} catch {
Toast.error('Failed to trigger search');
}
}}
>
Search now
</Button>
</KpiCard>
</Col>
</Row>
</SegmentPart>
</Col> </Col>
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}> <Col xs={24} sm={12} md={12} lg={6} xl={6}>
<SegmentPart name="Overview" Icon={IconStar}> <KpiCard
<Row gutter={[16, 16]} className="dashboard__row"> title="Last Search"
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> valueFontSize="14px"
<KpiCard value={
title="Jobs" dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
color="blue" ? '---'
value={!kpis.totalJobs ? '---' : kpis.totalJobs} : format(dashboard?.general?.lastRun)
icon={<IconTerminal />} }
description="Total number of jobs" icon={<IconDoubleChevronLeft />}
/> description="Last execution timestamp"
</Col> />
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> </Col>
<KpiCard <Col xs={24} sm={12} md={12} lg={6} xl={6}>
title="Listings" <KpiCard
color="orange" title="Next Search"
value={!kpis.totalListings ? '---' : kpis.totalListings} value={
icon={<IconStarStroked />} dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
description="Total listings found" ? '---'
/> : format(dashboard?.general?.nextRun)
</Col> }
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> valueFontSize="14px"
<KpiCard icon={<IconDoubleChevronRight />}
title="Active Listings" description="Next execution timestamp"
color="green" />
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings} </Col>
icon={<IconStar />} <Col xs={24} sm={12} md={12} lg={6} xl={6}>
description="Total active listings" <KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
/> <Button
</Col> size="small"
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> style={{ marginTop: '.2rem' }}
<KpiCard icon={<IconPlayCircle />}
title="Avg. Price" aria-label="Start now"
color="purple" onClick={async () => {
value={`${ try {
!kpis.avgPriceOfListings await xhrPost('/api/jobs/startAll', null);
? '---' Toast.success('Successfully triggered Fredy search.');
: new Intl.NumberFormat('de-DE', { } catch {
style: 'currency', Toast.error('Failed to trigger search');
currency: 'EUR', }
}).format(kpis.avgPriceOfListings) }}
}`} >
icon={<IconNoteMoney />} Search now
description="Avg. Price of listings" </Button>
/> </KpiCard>
</Col>
</Row>
</SegmentPart>
</Col> </Col>
</Row> </Row>
<SegmentPart <Text className="dashboard__section-label">Overview</Text>
name="Provider Insights" <Row gutter={[16, 16]} className="dashboard__row">
Icon={IconStar} <Col xs={24} sm={12} md={12} lg={6} xl={6}>
helpText="Percentage of found listings over all providers" <KpiCard
className="dashboard__provider-insights" title="Jobs"
> color="blue"
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
icon={<IconTerminal />}
description="Total number of jobs"
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Listings"
color="orange"
value={!kpis.totalListings ? '---' : kpis.totalListings}
icon={<IconStarStroked />}
description="Total listings found"
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Active Listings"
color="green"
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
icon={<IconStar />}
description="Total active listings"
/>
</Col>
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Avg. Price"
color="purple"
value={`${
!kpis.avgPriceOfListings
? '---'
: new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(kpis.avgPriceOfListings)
}`}
icon={<IconNoteMoney />}
description="Avg. Price of listings"
/>
</Col>
</Row>
<Text className="dashboard__section-label">Provider Insights</Text>
<div className="dashboard__pie-wrapper">
<PieChartCard data={pieData} /> <PieChartCard data={pieData} />
</SegmentPart> </div>
</div> </div>
); );
} }

View File

@@ -3,31 +3,32 @@
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
&__row { &__section-label {
margin-bottom: 24px; display: block;
flex-wrap: wrap; font-size: 11px !important;
font-weight: 600 !important;
.semi-col { text-transform: uppercase;
margin-bottom: 0; // Handled by Row gutter letter-spacing: 0.08em;
} color: #5a6478 !important;
margin-bottom: 10px;
margin-top: 4px;
} }
&__provider-insights { &__row {
margin-bottom: 8px;
flex-wrap: wrap;
}
&__pie-wrapper {
background: #23242a;
border: 1px solid #37404e;
border-radius: 10px;
padding: 24px;
max-height: 320px;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0 !important; justify-content: center;
.semi-card-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-height: 300px;
> * {
flex: 1;
}
}
} }
} }

View File

@@ -3,31 +3,38 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import React from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { useActions, useSelector } from '../../services/state/store'; import { useActions, useSelector, useIsLoading } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19'; import {
Tabs,
TabPane,
TimePicker,
Button,
Checkbox,
Input,
Modal,
Typography,
AutoComplete,
Switch,
Banner,
} from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19'; import { InputNumber } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr'; import { xhrPost, xhrGet } from '../../services/xhr';
import { Toast } from '@douyinfe/semi-ui-19';
import { SegmentPart } from '../../components/segment/SegmentPart'; import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui-19';
import { import {
downloadBackup as downloadBackupZip, downloadBackup as downloadBackupZip,
precheckRestore as clientPrecheckRestore, precheckRestore as clientPrecheckRestore,
restore as clientRestore, restore as clientRestore,
} from '../../services/backupRestoreClient'; } from '../../services/backupRestoreClient';
import { import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
IconSave, import debounce from 'lodash/debounce';
IconCalendar,
IconRefresh,
IconSignal,
IconLineChartStroked,
IconSearch,
IconFolder,
} from '@douyinfe/semi-icons';
import './GeneralSettings.less'; import './GeneralSettings.less';
const { Text } = Typography;
function formatFromTimestamp(ts) { function formatFromTimestamp(ts) {
const date = new Date(ts); const date = new Date(ts);
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`; return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
@@ -63,6 +70,14 @@ const GeneralSettings = function GeneralSettings() {
const [restoreBusy, setRestoreBusy] = React.useState(false); const [restoreBusy, setRestoreBusy] = React.useState(false);
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null); const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
// User settings state
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
const [dataSource, setDataSource] = useState([]);
React.useEffect(() => { React.useEffect(() => {
async function init() { async function init() {
await actions.generalSettings.getGeneralSettings(); await actions.generalSettings.getGeneralSettings();
@@ -86,6 +101,11 @@ const GeneralSettings = function GeneralSettings() {
init(); init();
}, [settings]); }, [settings]);
useEffect(() => {
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const nullOrEmpty = (val) => val == null || val.length === 0; const nullOrEmpty = (val) => val == null || val.length === 0;
const handleStore = async () => { const handleStore = async () => {
@@ -177,7 +197,6 @@ const GeneralSettings = function GeneralSettings() {
if (!file) return; if (!file) return;
setSelectedRestoreFile(file); setSelectedRestoreFile(file);
await precheckRestore(file); await precheckRestore(file);
// reset the input to allow same file re-select
ev.target.value = ''; ev.target.value = '';
}, },
[precheckRestore], [precheckRestore],
@@ -189,180 +208,280 @@ const GeneralSettings = function GeneralSettings() {
} }
}, []); }, []);
const handleSaveUserSettings = async () => {
try {
const responseJson = await actions.userSettings.setHomeAddress(address);
setCoords(responseJson.coords);
await actions.userSettings.getUserSettings();
Toast.success('Settings saved. Distance calculations are running in the background.');
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
}
};
const debouncedSearch = useMemo(
() =>
debounce((value) => {
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
.then((response) => {
if (response.status === 200) {
setDataSource(response.json);
}
})
.catch(() => {});
}, 300),
[],
);
const searchAddress = (value) => {
if (!value) {
setDataSource([]);
return;
}
debouncedSearch(value);
};
return ( return (
<div> <div className="generalSettings">
{!loading && ( {!loading && (
<React.Fragment> <>
<div> <Tabs type="line">
<SegmentPart <TabPane
name="Interval" tab={
helpText="Interval in minutes for running queries against the configured services. Do NOT go under 5 minutes as with a lower interval, your instance might be detected as a bot." <span>
Icon={IconRefresh} <IconSignal size="small" style={{ marginRight: 6 }} />
System
</span>
}
itemKey="system"
> >
<InputNumber <div className="generalSettings__tab-content">
min={5} <SegmentPart name="Port" helpText="The port on which Fredy is running.">
max={1440} <InputNumber
placeholder="Interval in minutes" min={0}
value={interval} max={99999}
formatter={(value) => `${value}`.replace(/\D/g, '')} placeholder="Port"
onChange={(value) => setInterval(value)} value={port}
suffix={'minutes'} formatter={(value) => `${value}`.replace(/\D/g, '')}
/> onChange={(value) => setPort(value)}
</SegmentPart> style={{ maxWidth: 160 }}
<Divider margin="1rem" /> />
<SegmentPart </SegmentPart>
name="Backup & Restore"
helpText="Download a zipped backup of your database or restore it from a backup zip." <SegmentPart
Icon={IconSave} name="SQLite Database Path"
> helpText="The directory where Fredy stores its SQLite database files."
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}> >
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}> <Banner
Download backup fullMode={false}
</Button> type="warning"
<input closeIcon={null}
type="file" style={{ marginBottom: '12px' }}
accept=".zip,application/zip" description="Changing this path may result in data loss. Restart Fredy immediately after saving."
ref={fileInputRef} />
style={{ display: 'none' }} <Input
onChange={handleSelectRestoreFile} type="text"
/> placeholder="Database folder path"
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}> value={sqlitePath}
Restore from zip onChange={(value) => setSqlitePath(value)}
</Button> />
</SegmentPart>
<SegmentPart
name="Analytics"
helpText="Anonymous usage data to help improve Fredy — provider names, adapter names, OS, Node version, and architecture."
>
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
Enable analytics
</Checkbox>
</SegmentPart>
<SegmentPart
name="Demo Mode"
helpText="In demo mode, Fredy will not search for real estates and all data resets to defaults at midnight."
>
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
Enable demo mode
</Checkbox>
</SegmentPart>
<div className="generalSettings__save-row">
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
</Button>
</div>
</div> </div>
</SegmentPart> </TabPane>
<Divider margin="1rem" />
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
<InputNumber
min={0}
max={99999}
placeholder="Port"
value={port}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setPort(value)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="SQLite Database path"
helpText="The directory where Fredy stores its SQLite database files."
Icon={IconFolder}
>
<Banner
fullMode={false}
type="warning"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Warning</div>}
style={{ marginBottom: '1rem' }}
description={
<div>
Changing the path later may result in data loss.
<br />
You <b>must</b> restart Fredy immediately after changing this setting!
</div>
}
/>
<Input <TabPane
type="text" tab={
placeholder="Select folder" <span>
value={sqlitePath} <IconRefresh size="small" style={{ marginRight: 6 }} />
onChange={(value) => { Execution
setSqlitePath(value); </span>
}} }
/> itemKey="execution"
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Working hours"
helpText="During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
Icon={IconCalendar}
> >
<div className="generalSettings__timePickerContainer"> <div className="generalSettings__tab-content">
<TimePicker <SegmentPart
format={'HH:mm'} name="Search Interval"
insetLabel="From" helpText="Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot."
value={formatFromTBackend(workingHourFrom)} >
placeholder="" <InputNumber
onChange={(val) => { min={5}
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val)); max={1440}
}} placeholder="Interval in minutes"
/> value={interval}
<TimePicker formatter={(value) => `${value}`.replace(/\D/g, '')}
format={'HH:mm'} onChange={(value) => setInterval(value)}
insetLabel="Until" suffix={'minutes'}
value={formatFromTBackend(workingHourTo)} style={{ maxWidth: 200 }}
placeholder="" />
onChange={(val) => { </SegmentPart>
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
}} <SegmentPart
/> name="Working Hours"
helpText="Fredy will only search for listings during these hours. Leave empty to search around the clock."
>
<div className="generalSettings__timePickerContainer">
<TimePicker
format={'HH:mm'}
insetLabel="From"
value={formatFromTBackend(workingHourFrom)}
placeholder=""
onChange={(val) => {
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
}}
/>
<TimePicker
format={'HH:mm'}
insetLabel="Until"
value={formatFromTBackend(workingHourTo)}
placeholder=""
onChange={(val) => {
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
}}
/>
</div>
</SegmentPart>
<div className="generalSettings__save-row">
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
</Button>
</div>
</div> </div>
</SegmentPart> </TabPane>
<Divider margin="1rem" />
<SegmentPart name="Analytics" helpText="Insights into the usage of Fredy." Icon={IconLineChartStroked}> <TabPane
<Banner tab={
fullMode={false} <span>
type="info" <IconFolder size="small" style={{ marginRight: 6 }} />
closeIcon={null} Backup & Restore
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>} </span>
style={{ marginBottom: '1rem' }} }
description={ itemKey="backup"
<div> >
Analytics are disabled by default. If you choose to enable them, we will begin tracking the <div className="generalSettings__tab-content">
following: <SegmentPart
<br /> name="Backup & Restore"
<ul> helpText="Download a zipped backup of your database or restore from a backup zip."
<li>Name of active provider (e.g. Immoscout)</li> >
<li>Name of active adapter (e.g. Console)</li> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<li>language</li> <Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
<li>os</li> Download Backup
<li>node version</li> </Button>
<li>arch</li> <input
</ul> type="file"
The data is sent anonymously and helps me understand which providers or adapters are being used the accept=".zip,application/zip"
most. In the end it helps me to improve fredy. ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleSelectRestoreFile}
/>
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
Restore from Zip
</Button>
</div> </div>
} </SegmentPart>
/> </div>
</TabPane>
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}> <TabPane
{' '} tab={
Enabled <span>
</Checkbox> <IconHome size="small" style={{ marginRight: 6 }} />
</SegmentPart> User Settings
</span>
}
itemKey="userSettings"
>
<div className="generalSettings__tab-content">
<SegmentPart
name="Home Address"
helpText="Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings."
>
<AutoComplete
data={dataSource}
value={address}
showClear
onChange={(v) => setAddress(v)}
onSearch={searchAddress}
placeholder="Enter your home address"
style={{ width: '100%' }}
/>
{coords && coords.lat === -1 && (
<Banner
type="danger"
description="Address found but could not be geocoded accurately."
closeIcon={null}
style={{ marginTop: 8 }}
/>
)}
</SegmentPart>
<Divider margin="1rem" /> <SegmentPart
name="ImmoScout Details"
<SegmentPart name="Demo Mode" helpText="If enabled, Fredy runs in demo mode." Icon={IconSearch}> helpText="Fetch additional details (description, attributes, agent info) for ImmoScout listings. Makes an extra API call per listing."
<Banner >
fullMode={false} <Banner
type="info" type="warning"
closeIcon={null} description="Enabling this significantly increases API requests to ImmoScout, raising the chance of rate limiting or blocking. Use at your own risk."
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>} closeIcon={null}
style={{ marginBottom: '1rem' }} style={{ marginBottom: 12 }}
description={ />
<div> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
In demo mode, Fredy will not (really) search for any real estates. Fredy is in a lockdown mode. Also <Switch
all database files will be set back to the default values at midnight. checked={!!immoscoutDetails}
onChange={async (checked) => {
try {
await actions.userSettings.setImmoscoutDetails(checked);
Toast.success('ImmoScout details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
<Text>Fetch detailed ImmoScout listings</Text>
</div> </div>
} </SegmentPart>
/>
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}> <div className="generalSettings__save-row">
{' '} <Button
Enabled icon={<IconSave />}
</Checkbox> theme="solid"
</SegmentPart> type="primary"
onClick={handleSaveUserSettings}
<Divider margin="1rem" /> loading={saving}
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}> >
Save Save
</Button> </Button>
</div> </div>
</React.Fragment> </div>
</TabPane>
</Tabs>
</>
)} )}
{restoreModalVisible && ( {restoreModalVisible && (
<Modal <Modal
title="Restore database" title="Restore database"

View File

@@ -1,12 +1,17 @@
.generalSettings { .generalSettings {
&__tab-content {
padding: 20px 0;
max-width: 860px;
}
&__timePickerContainer { &__timePickerContainer {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
&__help { &__save-row {
font-size: 11px; margin-top: 1.5rem;
margin-left: 1rem;
} }
} }

View File

@@ -158,11 +158,11 @@ export default function NotificationAdapterMutator({
{uiElement.type === 'boolean' ? ( {uiElement.type === 'boolean' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Switch <Switch
checked={uiElement.value || false} checked={uiElement.value || false}
onChange={(checked) => { onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked); setValue(selectedAdapter, uiElement, key, checked);
}} }}
/> />
{uiElement.label} {uiElement.label}
</div> </div>
) : ( ) : (
@@ -173,6 +173,7 @@ export default function NotificationAdapterMutator({
initValue={uiElement.value ?? ''} initValue={uiElement.value ?? ''}
placeholder={uiElement.label} placeholder={uiElement.label}
label={uiElement.label} label={uiElement.label}
extraText={uiElement.description}
onChange={(value) => { onChange={(value) => {
setValue(selectedAdapter, uiElement, key, value); setValue(selectedAdapter, uiElement, key, value);
}} }}

View File

@@ -4,24 +4,26 @@
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { parseBoolean, parseNumber, parseString, useSearchParamState } from '../../hooks/useSearchParamState.js';
import { renderToString } from 'react-dom/server'; import { renderToString } from 'react-dom/server';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js'; import { useActions, useSelector } from '../../services/state/store.js';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js'; import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19'; import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19';
import { IconFilter, IconLink } from '@douyinfe/semi-icons'; import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons';
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg'; import no_image from '../../assets/no_image.jpg';
import RangeSlider from 'react-range-slider-input'; import _RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css'; import 'react-range-slider-input/dist/style.css';
import './Map.less'; import './Map.less';
import { xhrDelete } from '../../services/xhr.js'; import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx'; import Map from '../../components/map/Map.jsx';
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
const { Text } = Typography; const { Text } = Typography;
export default function MapView() { export default function MapView() {
@@ -31,16 +33,21 @@ export default function MapView() {
const homeMarker = useRef(null); const homeMarker = useRef(null);
const actions = useActions(); const actions = useActions();
const navigate = useNavigate(); const navigate = useNavigate();
const sp = useSearchParams();
const [searchParams, setSearchParams] = sp;
const listings = useSelector((state) => state.listingsData.mapListings); const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [style, setStyle] = useState('STANDARD');
const [show3dBuildings, setShow3dBuildings] = useState(false);
const jobs = useSelector((state) => state.jobsData.jobs); const jobs = useSelector((state) => state.jobsData.jobs);
const [jobId, setJobId] = useState(null); const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
const [priceRange, setPriceRange] = useState([0, 0]); const [distanceFilter, setDistanceFilter] = useSearchParamState(sp, 'distance', 0, parseNumber);
const [showFilterBar, setShowFilterBar] = useState(false); const [style] = useSearchParamState(sp, 'style', 'STANDARD', parseString);
const [distanceFilter, setDistanceFilter] = useState(0); const [show3dBuildings, setShow3dBuildings] = useSearchParamState(sp, 'buildings', false, parseBoolean);
// Price range: stored as priceMin/priceMax URL params; default max derived from loaded listings
const urlPriceMin = searchParams.has('priceMin') ? Number(searchParams.get('priceMin')) : null;
const urlPriceMax = searchParams.has('priceMax') ? Number(searchParams.get('priceMax')) : null;
const [priceRange, setPriceRange] = useState([urlPriceMin ?? 0, urlPriceMax ?? 0]);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null); const [listingToDelete, setListingToDelete] = useState(null);
@@ -59,14 +66,17 @@ export default function MapView() {
}; };
useEffect(() => { useEffect(() => {
setPriceRange([0, getMaxPrice()]); // Only reset to full range when no URL override is set
if (urlPriceMax === null) {
setPriceRange([0, getMaxPrice()]);
}
}, [listings]); }, [listings]);
const getMaxPrice = () => { const getMaxPrice = () => {
return listings.reduce((max, item) => { return listings.reduce((acc, item) => {
const price = Number(item.price); const price = Number(item.price);
return Number.isFinite(price) && price > max ? price : max; return Number.isFinite(price) && price > acc ? price : acc;
}, -Infinity); }, 0);
}; };
const filterListings = () => { const filterListings = () => {
@@ -92,10 +102,8 @@ export default function MapView() {
}; };
}, [navigate]); }, [navigate]);
// Get map instance reference after MapComponent renders
useEffect(() => { useEffect(() => {
if (mapContainer.current && !map.current) { if (mapContainer.current && !map.current) {
// Wait for MapComponent to initialize the map
const checkMapReady = () => { const checkMapReady = () => {
if (mapContainer.current?.map) { if (mapContainer.current?.map) {
map.current = mapContainer.current.map; map.current = mapContainer.current.map;
@@ -111,11 +119,45 @@ export default function MapView() {
map.current = mapInstance; map.current = mapInstance;
}; };
const setMapStyle = (value) => { const handleMapStyle = (value) => {
setStyle(value); setSearchParams(
if (value === 'SATELLITE') { (prev) => {
setShow3dBuildings(false); const next = new URLSearchParams(prev);
} if (value === 'STANDARD') {
next.delete('style');
} else {
next.set('style', value);
}
if (value === 'SATELLITE') {
next.delete('buildings');
}
return next;
},
{ replace: true },
);
};
const handlePriceRange = (val) => {
const maxPrice = getMaxPrice();
if (maxPrice <= 0) return; // skip until listings are loaded
setPriceRange(val);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (val[0] === 0) {
next.delete('priceMin');
} else {
next.set('priceMin', String(val[0]));
}
if (val[1] === 0 || val[1] >= maxPrice) {
next.delete('priceMax');
} else {
next.set('priceMax', String(val[1]));
}
return next;
},
{ replace: true },
);
}; };
const fetchListings = async () => { const fetchListings = async () => {
@@ -132,8 +174,6 @@ export default function MapView() {
if (!map.current) return; if (!map.current) return;
if (homeAddress?.coords) { if (homeAddress?.coords) {
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
// not on every render. useEffect dependency array handles this.
if (distanceFilter > 0) { if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter); const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
@@ -290,7 +330,7 @@ export default function MapView() {
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent); const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
let color = '#3FB1CE'; // Default blue-ish let color = '#3FB1CE';
if (distanceFilter > 0 && homeAddress?.coords) { if (distanceFilter > 0 && homeAddress?.coords) {
const dist = distanceMeters( const dist = distanceMeters(
homeAddress.coords.lat, homeAddress.coords.lat,
@@ -315,114 +355,17 @@ export default function MapView() {
return ( return (
<div className="map-view-container"> <div className="map-view-container">
<div className="listingsGrid__searchbar map-filter-bar">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexGrow: 1 }}>
<Text strong>Map View</Text>
<Select placeholder="Style" style={{ width: 120 }} value={style} onChange={(val) => setMapStyle(val)}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
<Text strong>3D Buildings</Text>
<Switch size="small" checked={show3dBuildings} onChange={(v) => setShow3dBuildings(v)} />
</div>
</div>
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
</div>
{showFilterBar && (
<div className="listingsGrid__toolbar">
<Space wrap style={{ marginBottom: '1rem' }}>
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Filter by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
<Select
placeholder="Job"
showClear
style={{ width: 150 }}
onChange={(val) => {
setJobId(val);
}}
value={jobId}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Distance:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
<Select
placeholder="Distance"
style={{ width: 100 }}
onChange={(val) => {
setDistanceFilter(val);
}}
value={distanceFilter}
>
<Select.Option value={0}>---</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Price Range ():</Text>
</div>
<div style={{ width: 250, padding: '0 10px' }}>
<div className="map__rangesliderLabels">
<span>{priceRange[0]} </span>
<span>{priceRange[1]} </span>
</div>
<RangeSlider
min={0}
max={getMaxPrice()}
step={100}
value={priceRange}
onInput={(val) => {
setPriceRange(val);
}}
tipFormatter={(val) => `${val}`}
/>
</div>
</div>
</Space>
</div>
)}
{!homeAddress && ( {!homeAddress && (
<Banner <Banner
fullMode={true} fullMode={true}
type="warning" type="warning"
bordered bordered
closeIcon={null} closeIcon={null}
style={{ marginBottom: '8px' }}
description={ description={
<span> <span>
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '} No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
to use the distance filter. filter.
</span> </span>
} }
/> />
@@ -433,10 +376,97 @@ export default function MapView() {
type="info" type="info"
bordered bordered
closeIcon={null} closeIcon={null}
description="Keep in mind, only listings with proper adresses are being shown on this map." style={{ marginBottom: '8px' }}
description="Only listings with valid addresses are shown on this map."
/> />
<Map mapContainerRef={mapContainer} style={style} show3dBuildings={show3dBuildings} onMapReady={handleMapReady} /> <div className="map-view-container__map-wrapper">
<Map
mapContainerRef={mapContainer}
style={style}
show3dBuildings={show3dBuildings}
onMapReady={handleMapReady}
/>
{/* Floating filter panel */}
<div className="map-view-container__floating-panel">
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Job
</Text>
<Select
placeholder="All jobs"
showClear
size="small"
onChange={(val) => setJobId(val)}
value={jobId}
style={{ width: 160 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Distance
</Text>
<Select
placeholder="None"
size="small"
onChange={(val) => setDistanceFilter(val)}
value={distanceFilter}
style={{ width: 100 }}
>
<Select.Option value={0}>None</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Price ()
</Text>
<div className="map-view-container__price-slider">
<div className="map__rangesliderLabels">
<span>{priceRange[0]}</span>
<span>{priceRange[1]}</span>
</div>
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
</div>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Style
</Text>
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
3D Buildings
</Text>
<Switch
size="small"
checked={show3dBuildings}
onChange={(v) => setShow3dBuildings(v)}
disabled={style === 'SATELLITE'}
/>
</div>
</div>
</div>
<ListingDeletionModal <ListingDeletionModal
visible={deleteModalVisible} visible={deleteModalVisible}
onConfirm={confirmListingDeletion} onConfirm={confirmListingDeletion}

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 by Christian Kellner. * Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
@@ -9,18 +9,48 @@
height: 100%; height: 100%;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
}
.map-filter-bar { &__map-wrapper {
margin-bottom: 1rem; position: relative;
flex: 1;
min-height: 0;
}
&__floating-panel {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
background: rgba(13, 15, 20, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid #262a3a;
border-radius: 10px;
padding: 14px 16px;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 12px;
}
&__panel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&__price-slider {
width: 140px;
}
} }
.map-container { .map-container {
flex-grow: 1;
width: 100%; width: 100%;
height: 100%;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--semi-color-border); border: 1px solid #262a3a;
} }
.map-popup-content { .map-popup-content {
@@ -126,7 +156,7 @@
} }
} }
/* Override MapLibre default popup styles to match application theme */ /* Override MapLibre default popup styles */
.maplibregl-popup-content { .maplibregl-popup-content {
background-color: var(--semi-color-bg-1) !important; background-color: var(--semi-color-bg-1) !important;
color: var(--semi-color-text-0) !important; color: var(--semi-color-text-0) !important;
@@ -140,21 +170,26 @@
} }
.map { .map {
&__rangesliderLabels{ &__rangesliderLabels {
color: white; color: #94a3b8;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: .3rem; margin-bottom: .3rem;
font-size: .7rem; font-size: .7rem;
} }
} }
.range-slider .range-slider__thumb { .range-slider .range-slider__thumb {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
top: 50%; top: 50%;
width: 16px; width: 14px;
height: 16px; height: 14px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
border-radius: 50%; border-radius: 50%;
background: #2196f3; background: #0ab5b3;
} }
.range-slider .range-slider__range {
background: #0ab5b3;
}

View File

@@ -10,7 +10,7 @@ import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr'; import { xhrPost } from '../../services/xhr';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useActions, useSelector } from '../../services/state/store'; import { useActions, useSelector } from '../../services/state/store';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui-19'; import { Input, Button, Banner } from '@douyinfe/semi-ui-19';
import './login.less'; import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons'; import { IconUser, IconLock } from '@douyinfe/semi-icons';
@@ -45,12 +45,10 @@ export default function Login() {
}); });
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
} catch (ignored) { } catch (ignored) {
Toast.error('Login unsuccessful'); setError('Login unsuccessful. Please check your username and password.');
return; return;
} }
Toast.success('Login successful!');
await actions.user.getCurrentUser(); await actions.user.getCurrentUser();
navigate('/dashboard'); navigate('/dashboard');
}; };

View File

@@ -12,6 +12,18 @@ export default defineConfig({
chunkSizeWarningLimit: 9999999, chunkSizeWarningLimit: 9999999,
outDir: './ui/public', outDir: './ui/public',
emptyOutDir: true, emptyOutDir: true,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/maplibre-gl')) {
return 'maplibre-gl';
}
},
},
},
},
optimizeDeps: {
include: ['maplibre-gl'],
}, },
plugins: [react()], plugins: [react()],
server: { server: {

16
vitest.config.js Normal file
View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/**/*.test.js'],
testTimeout: 60000,
reporters: ['verbose'],
},
});

21
vitest.gh.config.js Normal file
View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { defineConfig, mergeConfig } from 'vitest/config';
import base from './vitest.config.js';
export default mergeConfig(
base,
defineConfig({
test: {
exclude: [
'**/node_modules/**',
'test/provider/immonet.test.js',
'test/provider/immobilienDe.test.js',
'test/provider/immowelt.test.js',
],
},
}),
);

1452
yarn.lock

File diff suppressed because it is too large Load Diff