Skip to content

Commit 8777165

Browse files
committed
adding new dashboard view. Muchas wow
1 parent 87b5673 commit 8777165

31 files changed

+688
-1047
lines changed

doc/screenshot3.png

30 KB
Loading

lib/api/api.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
77
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
88
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
9-
import { analyticsRouter } from './routes/analyticsRouter.js';
109
import { providerRouter } from './routes/providerRouter.js';
1110
import { versionRouter } from './routes/versionRouter.js';
1211
import { loginRouter } from './routes/loginRoute.js';
@@ -22,6 +21,7 @@ import logger from '../services/logger.js';
2221
import { listingsRouter } from './routes/listingsRouter.js';
2322
import { getSettings } from '../services/storage/settingsStorage.js';
2423
import { featureRouter } from './routes/featureRouter.js';
24+
import { dashboardRouter } from './routes/dashboardRouter.js';
2525
const service = restana();
2626
const staticService = files(path.join(getDirName(), '../ui/public'));
2727
const PORT = (await getSettings()).port || 9998;
@@ -33,19 +33,21 @@ service.use('/api/admin', authInterceptor());
3333
service.use('/api/jobs', authInterceptor());
3434
service.use('/api/version', authInterceptor());
3535
service.use('/api/listings', authInterceptor());
36+
service.use('/api/dashboard', authInterceptor());
37+
service.use('/api/features', authInterceptor());
3638

3739
// /admin can only be accessed when user is having admin permissions
3840
service.use('/api/admin', adminInterceptor());
3941
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
4042
service.use('/api/admin/generalSettings', generalSettingsRouter);
4143
service.use('/api/jobs/provider', providerRouter);
42-
service.use('/api/jobs/insights', analyticsRouter);
4344
service.use('/api/admin/users', userRouter);
4445
service.use('/api/version', versionRouter);
4546
service.use('/api/jobs', jobRouter);
4647
service.use('/api/login', loginRouter);
4748
service.use('/api/listings', listingsRouter);
4849
service.use('/api/features', featureRouter);
50+
service.use('/api/dashboard', dashboardRouter);
4951
//this route is unsecured intentionally as it is being queried from the login page
5052
service.use('/api/demo', demoRouter);
5153

lib/api/routes/analyticsRouter.js

Lines changed: 0 additions & 15 deletions
This file was deleted.

lib/api/routes/dashboardRouter.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright (c) 2025 by Christian Kellner.
3+
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
4+
*/
5+
6+
import restana from 'restana';
7+
import * as jobStorage from '../../services/storage/jobStorage.js';
8+
import * as userStorage from '../../services/storage/userStorage.js';
9+
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
10+
import { getSettings } from '../../services/storage/settingsStorage.js';
11+
12+
const service = restana();
13+
export const dashboardRouter = service.newRouter();
14+
15+
function isAdmin(req) {
16+
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
17+
return !!user?.isAdmin;
18+
}
19+
20+
function getAccessibleJobs(req) {
21+
const currentUser = req.session.currentUser;
22+
const admin = isAdmin(req);
23+
return jobStorage
24+
.getJobs()
25+
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
26+
}
27+
28+
function cap(val) {
29+
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
30+
}
31+
32+
dashboardRouter.get('/', async (req, res) => {
33+
const jobs = getAccessibleJobs(req);
34+
const settings = await getSettings();
35+
36+
// KPIs
37+
const totalJobs = jobs.length;
38+
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
39+
const jobIds = jobs.map((j) => j.id);
40+
const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds);
41+
// Build Pie data in a simple shape the frontend can consume directly
42+
// Shape: { labels: string[], values: number[] } with values as percentages
43+
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
44+
const providerPie = Array.isArray(providerPieRaw)
45+
? {
46+
labels: providerPieRaw.map((p) => cap(p.type)),
47+
values: providerPieRaw.map((p) => Number(p.value) || 0),
48+
}
49+
: providerPieRaw && typeof providerPieRaw === 'object'
50+
? {
51+
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
52+
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
53+
}
54+
: { labels: [], values: [] };
55+
56+
res.body = {
57+
general: {
58+
interval: settings.interval,
59+
lastRun: settings.lastRun || null,
60+
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
61+
},
62+
kpis: {
63+
totalJobs,
64+
totalListings,
65+
numberOfActiveListings,
66+
avgPriceOfListings,
67+
},
68+
pie: providerPie,
69+
};
70+
res.send();
71+
});

lib/api/routes/jobRouter.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import * as userStorage from '../../services/storage/userStorage.js';
99
import { isAdmin } from '../security.js';
1010
import logger from '../../services/logger.js';
1111
import { bus } from '../../services/events/event-bus.js';
12-
import { getSettings } from '../../services/storage/settingsStorage.js';
1312

1413
const service = restana();
1514
const jobRouter = service.newRouter();
@@ -48,15 +47,6 @@ jobRouter.get('/', async (req, res) => {
4847
res.send();
4948
});
5049

51-
jobRouter.get('/processingTimes', async (req, res) => {
52-
const settings = await getSettings();
53-
res.body = {
54-
interval: settings.interval,
55-
lastRun: settings.lastRun || null,
56-
};
57-
res.send();
58-
});
59-
6050
jobRouter.post('/startAll', async (req, res) => {
6151
bus.emit('jobs:runAll');
6252
res.send();

lib/services/storage/listingsStorage.js

Lines changed: 83 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,6 @@ import { nullOrEmpty } from '../../utils.js';
77
import SqliteConnection from './SqliteConnection.js';
88
import { nanoid } from 'nanoid';
99

10-
/**
11-
* Build analytics data for a given job by grouping all listings by provider and
12-
* mapping each listing hash to its creation timestamp.
13-
*
14-
* SQL shape:
15-
* SELECT json_group_object(provider, json_object(hash, created_at)) AS result
16-
* FROM listings WHERE job_id = @jobId;
17-
*
18-
* The resulting object has the shape:
19-
* {
20-
* providerA: { "<hash1>": <created_at_ms>, "<hash2>": <created_at_ms>, ... },
21-
* providerB: { ... }
22-
* }
23-
*
24-
* @param {string} jobId - ID of the job whose listings should be aggregated.
25-
* @returns {Record<string, Record<string, number>>} Object grouped by provider mapping listing-hash -> created_at epoch ms.
26-
*/
27-
export const getListingProviderDataForAnalytics = (jobId) => {
28-
const row = SqliteConnection.query(
29-
`SELECT COALESCE(
30-
json_group_object(provider, json(provider_map)),
31-
json('{}')
32-
) AS result
33-
FROM (SELECT provider,
34-
json_group_object(hash, created_at) AS provider_map
35-
FROM listings
36-
WHERE job_id = @jobId
37-
GROUP BY provider);`,
38-
{ jobId },
39-
);
40-
41-
return row?.length > 0 ? JSON.parse(row[0].result) : {};
42-
};
43-
4410
/**
4511
* Return a list of known listing hashes for a given job and provider.
4612
* Useful to de-duplicate before inserting new listings.
@@ -59,6 +25,89 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
5925
).map((r) => r.hash);
6026
};
6127

28+
/**
29+
* Compute KPI aggregates for a given set of job IDs from the listings table.
30+
*
31+
* - numberOfActiveListings: count of listings where is_active = 1
32+
* - avgPriceOfListings: average of numeric price, rounded to nearest integer
33+
*
34+
* When no jobIds are provided, returns zeros.
35+
*
36+
* @param {string[]} jobIds
37+
* @returns {{ numberOfActiveListings: number, avgPriceOfListings: number }}
38+
*/
39+
export const getListingsKpisForJobIds = (jobIds = []) => {
40+
if (!Array.isArray(jobIds) || jobIds.length === 0) {
41+
return { numberOfActiveListings: 0, avgPriceOfListings: 0 };
42+
}
43+
44+
const placeholders = jobIds.map(() => '?').join(',');
45+
const row =
46+
SqliteConnection.query(
47+
`SELECT
48+
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
49+
AVG(price) AS avgPrice
50+
FROM listings
51+
WHERE job_id IN (${placeholders})`,
52+
jobIds,
53+
)[0] || {};
54+
55+
return {
56+
numberOfActiveListings: Number(row.activeCount || 0),
57+
avgPriceOfListings: row?.avgPrice == null ? 0 : Math.round(Number(row.avgPrice)),
58+
};
59+
};
60+
61+
/**
62+
* Compute distribution of listings by provider for the given set of job IDs.
63+
* Returns data ready for the pie chart component with fields `type` and `value` (percentage).
64+
*
65+
* Example return:
66+
* [ { type: 'immoscout', value: 62 }, { type: 'immowelt', value: 38 } ]
67+
*
68+
* When no jobIds are provided or no listings exist, returns empty array.
69+
*
70+
* @param {string[]} jobIds
71+
* @returns {{ type: string, value: number }[]}
72+
*/
73+
export const getProviderDistributionForJobIds = (jobIds = []) => {
74+
if (!Array.isArray(jobIds) || jobIds.length === 0) {
75+
return [];
76+
}
77+
78+
const placeholders = jobIds.map(() => '?').join(',');
79+
const rows = SqliteConnection.query(
80+
`SELECT provider, COUNT(*) AS cnt
81+
FROM listings
82+
WHERE job_id IN (${placeholders})
83+
GROUP BY provider
84+
ORDER BY cnt DESC`,
85+
jobIds,
86+
);
87+
88+
const total = rows.reduce((acc, r) => acc + Number(r.cnt || 0), 0);
89+
if (total === 0) return [];
90+
91+
// Map counts to integer percentage values (0-100). Ensure sum is ~100 by rounding.
92+
const percentages = rows.map((r) => ({
93+
type: r.provider,
94+
value: Math.round((Number(r.cnt) / total) * 100),
95+
}));
96+
97+
// Adjust rounding drift to keep sum at 100 (optional minor correction)
98+
const drift = 100 - percentages.reduce((s, p) => s + p.value, 0);
99+
if (drift !== 0 && percentages.length > 0) {
100+
// apply drift to the largest slice to keep UX simple
101+
let maxIdx = 0;
102+
for (let i = 1; i < percentages.length; i++) {
103+
if (percentages[i].value > percentages[maxIdx].value) maxIdx = i;
104+
}
105+
percentages[maxIdx].value = Math.max(0, percentages[maxIdx].value + drift);
106+
}
107+
108+
return percentages;
109+
};
110+
62111
/**
63112
* Return a list of listing that either are active or have an unknown status
64113
* to constantly check if they are still online

package.json

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fredy",
3-
"version": "16.0.1",
3+
"version": "16.1.0",
44
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
55
"scripts": {
66
"prepare": "husky",
@@ -62,12 +62,10 @@
6262
"@douyinfe/semi-icons": "^2.89.0",
6363
"@douyinfe/semi-ui": "2.89.0",
6464
"@sendgrid/mail": "8.1.6",
65-
"@visactor/react-vchart": "^2.0.10",
66-
"@visactor/vchart": "^2.0.10",
67-
"@visactor/vchart-semi-theme": "^1.12.2",
6865
"@vitejs/plugin-react": "5.1.2",
6966
"better-sqlite3": "^12.5.0",
7067
"body-parser": "2.2.1",
68+
"chart.js": "^4.5.1",
7169
"cheerio": "^1.1.2",
7270
"cookie-session": "2.1.1",
7371
"handlebars": "4.7.8",
@@ -78,11 +76,12 @@
7876
"node-mailjet": "6.0.11",
7977
"p-throttle": "^8.1.0",
8078
"package-up": "^5.0.0",
81-
"puppeteer": "^24.32.1",
79+
"puppeteer": "^24.33.0",
8280
"puppeteer-extra": "^3.3.6",
8381
"puppeteer-extra-plugin-stealth": "^2.11.2",
8482
"query-string": "9.3.1",
8583
"react": "18.3.1",
84+
"react-chartjs-2": "^5.3.1",
8685
"react-dom": "18.3.1",
8786
"react-router": "7.10.1",
8887
"react-router-dom": "7.10.1",
@@ -100,7 +99,7 @@
10099
"@babel/preset-env": "7.28.5",
101100
"@babel/preset-react": "7.28.5",
102101
"chai": "6.2.1",
103-
"eslint": "9.39.1",
102+
"eslint": "9.39.2",
104103
"eslint-config-prettier": "10.1.8",
105104
"eslint-plugin-react": "7.37.5",
106105
"esmock": "2.7.3",

ui/src/App.jsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
1010
import GeneralSettings from './views/generalSettings/GeneralSettings';
1111
import JobMutation from './views/jobs/mutation/JobMutation';
1212
import UserMutator from './views/user/mutation/UserMutator';
13-
import JobInsight from './views/jobs/insights/JobInsight.jsx';
1413
import { useActions, useSelector } from './services/state/store';
1514
import { Routes, Route, Navigate } from 'react-router-dom';
1615
import Login from './views/login/Login';
@@ -25,16 +24,15 @@ import Listings from './views/listings/Listings.jsx';
2524
import Navigation from './components/navigation/Navigation.jsx';
2625
import { Layout } from '@douyinfe/semi-ui';
2726
import FredyFooter from './components/footer/FredyFooter.jsx';
28-
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
2927
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
28+
import Dashboard from './views/dashboard/Dashboard.jsx';
3029

3130
export default function FredyApp() {
3231
const actions = useActions();
3332
const [loading, setLoading] = React.useState(true);
3433
const currentUser = useSelector((state) => state.user.currentUser);
3534
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
3635
const settings = useSelector((state) => state.generalSettings.settings);
37-
const processingTimes = useSelector((state) => state.jobs.processingTimes);
3836

3937
useEffect(() => {
4038
async function init() {
@@ -43,7 +41,6 @@ export default function FredyApp() {
4341
await actions.features.getFeatures();
4442
await actions.provider.getProvider();
4543
await actions.jobs.getJobs();
46-
await actions.jobs.getProcessingTimes();
4744
await actions.jobs.getSharableUserList();
4845
await actions.notificationAdapter.getAdapter();
4946
await actions.generalSettings.getGeneralSettings();
@@ -88,14 +85,13 @@ export default function FredyApp() {
8885
</>
8986
)}
9087
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
91-
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
9288
<Divider />
9389
<div className="app__content">
9490
<Routes>
9591
<Route path="/403" element={<InsufficientPermission />} />
9692
<Route path="/jobs/new" element={<JobMutation />} />
9793
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
98-
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
94+
<Route path="/dashboard" element={<Dashboard />} />
9995
<Route path="/jobs" element={<Jobs />} />
10096
<Route path="/listings" element={<Listings />} />
10197
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
@@ -134,7 +130,7 @@ export default function FredyApp() {
134130
}
135131
/>
136132

137-
<Route path="/" element={<Navigate to="/jobs" replace />} />
133+
<Route path="/" element={<Navigate to="/dashboard" replace />} />
138134
</Routes>
139135
</div>
140136
</Content>

0 commit comments

Comments
 (0)