From 59f8c7539c9835888f9e724e0bd1d72856f9131a Mon Sep 17 00:00:00 2001 From: orangecoding Date: Mon, 21 Sep 2026 14:27:11 +0200 Subject: [PATCH 01/15] improving dashboard --- lib/api/routes/dashboardRouter.js | 4 + lib/services/storage/listingsStorage.js | 93 ++++ test/api/dashboardLatest.test.js | 221 +++++++++ test/services/jobs/dashboardRouter.test.js | 29 ++ test/ui/dashboardLayout.test.js | 107 ++++ ui/src/components/cards/DashboardCard.less | 17 +- ui/src/components/cards/KpiCard.jsx | 4 +- ui/src/components/cards/TrendSparkline.jsx | 89 ---- ui/src/locales/de.json | 31 +- ui/src/locales/en.json | 31 +- ui/src/locales/es.json | 29 +- ui/src/locales/it.json | 29 +- ui/src/locales/tr.json | 31 +- ui/src/themes.less | 8 + ui/src/tokens.less | 1 + ui/src/views/dashboard/Dashboard.jsx | 300 ++++++------ ui/src/views/dashboard/Dashboard.less | 456 +++++++++++++++--- .../views/dashboard/components/JobsPanel.jsx | 119 +++++ .../dashboard/components/LatestListings.jsx | 123 +++++ .../views/dashboard/components/TrendBars.jsx | 111 +++++ ui/src/views/dashboard/dashboardTime.js | 38 ++ 21 files changed, 1505 insertions(+), 366 deletions(-) create mode 100644 test/api/dashboardLatest.test.js create mode 100644 test/ui/dashboardLayout.test.js delete mode 100644 ui/src/components/cards/TrendSparkline.jsx create mode 100644 ui/src/views/dashboard/components/JobsPanel.jsx create mode 100644 ui/src/views/dashboard/components/LatestListings.jsx create mode 100644 ui/src/views/dashboard/components/TrendBars.jsx create mode 100644 ui/src/views/dashboard/dashboardTime.js diff --git a/lib/api/routes/dashboardRouter.js b/lib/api/routes/dashboardRouter.js index e7797279..e6647903 100644 --- a/lib/api/routes/dashboardRouter.js +++ b/lib/api/routes/dashboardRouter.js @@ -5,6 +5,8 @@ import * as jobStorage from '../../services/storage/jobStorage.js'; import { + getLatestListingsForJobIds, + getListingActivityPerJob, getListingsKpisForJobIds, getListingsPerDayForJobIds, getProviderDistributionForJobIds, @@ -121,6 +123,8 @@ export default async function dashboardPlugin(fastify) { ...compareWeeks(perDay), }, pie: providerPie, + latest: getLatestListingsForJobIds(jobIds, 8), + jobActivity: getListingActivityPerJob(jobIds, 7), }; }); } diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index cce73bfd..aecb4c79 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -357,6 +357,99 @@ export const getProviderDistributionForJobIds = (jobIds = []) => { return percentages; }; + +/** + * The newest active listings across the given jobs. + * + * The dashboard's reason to exist is "what came in", and that question was one navigation away. + * Active only, because a dashboard that leads with listings that are already gone wastes the one + * place a user looks first. + * + * @param {string[]} jobIds + * @param {number} [limit=8] + * @returns {Array} + */ +export const getLatestListingsForJobIds = (jobIds = [], limit = 8) => { + if (!Array.isArray(jobIds) || jobIds.length === 0) { + return []; + } + // Interpolated rather than bound, like the median query above: SQLite takes no parameter in + // LIMIT. Bounded to an integer first so nothing but a number can reach the statement. + const count = Number.isInteger(limit) && limit > 0 && limit <= 50 ? limit : 8; + const placeholders = jobIds.map(() => '?').join(','); + return SqliteConnection.query( + `SELECT id, title, address, price, size, rooms, price_per_sqm, image_url, created_at, job_id + FROM listings + WHERE job_id IN (${placeholders}) + AND manually_deleted = 0 + AND is_active = 1 + ORDER BY created_at DESC + LIMIT ${count}`, + jobIds, + ); +}; + +/** + * Per job: how many listings came in on each of the last `days` days, and the total over them. + * + * One grouped read rather than one query per job, because the dashboard already knows the job list + * and the point of the panel is the comparison between them. + * + * @param {string[]} jobIds + * @param {number} [days=7] + * @param {number} [now=Date.now()] + * @returns {Record} + */ +export const getListingActivityPerJob = (jobIds = [], days = 7, now = Date.now()) => { + if (!Array.isArray(jobIds) || jobIds.length === 0) { + return {}; + } + const span = Number.isFinite(days) && days > 0 ? Math.floor(days) : 7; + + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + const from = new Date(startOfToday); + from.setDate(from.getDate() - (span - 1)); + + // The empty calendar first, so a job that found nothing still yields a full series instead of + // being absent from the result and rendering as a missing row. + const dayKeys = []; + for (let offset = span - 1; offset >= 0; offset--) { + const day = new Date(startOfToday); + day.setDate(day.getDate() - offset); + dayKeys.push(toDayKey(day)); + } + + const activity = {}; + for (const jobId of jobIds) { + activity[jobId] = { perDay: new Array(span).fill(0), total: 0 }; + } + + const placeholders = jobIds.map(() => '?').join(','); + const rows = SqliteConnection.query( + `SELECT job_id, created_at + FROM listings + WHERE job_id IN (${placeholders}) + AND manually_deleted = 0 + AND created_at >= ?`, + [...jobIds, from.getTime()], + ); + + for (const row of rows) { + const entry = activity[row.job_id]; + if (entry == null) { + continue; + } + const index = dayKeys.indexOf(toDayKey(new Date(Number(row.created_at)))); + if (index >= 0) { + entry.perDay[index] += 1; + entry.total += 1; + } + } + + return activity; +}; + /** * Return the distinct provider identifiers that currently have non-deleted listings * within the user's accessible scope (optionally filtered by a specific job). diff --git a/test/api/dashboardLatest.test.js b/test/api/dashboardLatest.test.js new file mode 100644 index 00000000..e9e3e2c8 --- /dev/null +++ b/test/api/dashboardLatest.test.js @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; + +/** + * The two reads behind the dashboard's "what came in" panels. Both interpolate their own LIMIT and + * both cut day buckets in local time, so they run against a real in-memory database here: the + * clamping and the date arithmetic are the parts that break silently. + */ + +let db; + +vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({ + default: { + query: (sql, params) => db.prepare(sql).all(params ?? {}), + execute: (sql, params) => db.prepare(sql).run(params ?? {}), + withTransaction: (fn) => db.transaction(fn)(db), + }, +})); +vi.mock('../../lib/services/similarity-check/similarityCache.js', () => ({ removeEntry: vi.fn() })); + +/** Midday, so shifting by whole days never crosses a boundary by accident. */ +const NOW = new Date('2026-07-25T12:00:00').getTime(); + +/** Epoch ms for `offset` days before NOW, at midday. */ +const daysAgo = (offset) => { + const date = new Date(NOW); + date.setDate(date.getDate() - offset); + return date.getTime(); +}; + +describe('the dashboard listing reads', () => { + let listingsStorage; + let addListing; + + beforeEach(async () => { + db = new Database(':memory:'); + db.exec(` + CREATE TABLE listings ( + id TEXT PRIMARY KEY, + job_id TEXT, + title TEXT, + address TEXT, + price REAL, + size REAL, + rooms REAL, + price_per_sqm REAL, + image_url TEXT, + created_at INTEGER, + manually_deleted INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1 + ); + `); + const insert = db.prepare( + `INSERT INTO listings (id, job_id, title, address, price, size, rooms, price_per_sqm, image_url, + created_at, manually_deleted, is_active) + VALUES (@id, @jobId, @title, @address, @price, @size, @rooms, @pricePerSqm, @imageUrl, + @createdAt, @manuallyDeleted, @isActive)`, + ); + let seq = 0; + addListing = ({ jobId = 'job-1', createdAt = NOW, manuallyDeleted = 0, isActive = 1, title = null } = {}) => { + const id = `l${seq++}`; + insert.run({ + id, + jobId, + title: title ?? id, + address: 'Somewhere 1', + price: 900, + size: 60, + rooms: 2, + pricePerSqm: 15, + imageUrl: null, + createdAt, + manuallyDeleted, + isActive, + }); + return id; + }; + + vi.resetModules(); + listingsStorage = await import('../../lib/services/storage/listingsStorage.js'); + }); + + afterEach(() => db.close()); + + describe('getLatestListingsForJobIds', () => { + const latest = (jobIds, limit) => listingsStorage.getLatestListingsForJobIds(jobIds, limit); + + it('returns the newest first, because the panel answers "what came in"', () => { + const oldest = addListing({ createdAt: daysAgo(5) }); + const middle = addListing({ createdAt: daysAgo(2) }); + const newest = addListing({ createdAt: daysAgo(0) }); + + expect(latest(['job-1']).map((row) => row.id)).toEqual([newest, middle, oldest]); + }); + + it('leaves out an inactive listing, which is already gone', () => { + const active = addListing({ createdAt: daysAgo(3) }); + addListing({ createdAt: daysAgo(1), isActive: 0 }); + + expect(latest(['job-1']).map((row) => row.id)).toEqual([active]); + }); + + it('leaves out a soft-deleted listing, which the user no longer sees', () => { + const kept = addListing({ createdAt: daysAgo(3) }); + addListing({ createdAt: daysAgo(1), manuallyDeleted: 1 }); + + expect(latest(['job-1']).map((row) => row.id)).toEqual([kept]); + }); + + it('reads only the jobs it was given', () => { + const mine = addListing({ jobId: 'job-1' }); + addListing({ jobId: 'other' }); + + expect(latest(['job-1']).map((row) => row.id)).toEqual([mine]); + }); + + it('returns nothing without querying when there is no job to read', () => { + addListing({}); + expect(latest([])).toEqual([]); + expect(latest(null)).toEqual([]); + }); + + it('honours a limit inside the allowed range', () => { + for (let i = 0; i < 5; i++) { + addListing({ createdAt: daysAgo(i) }); + } + expect(latest(['job-1'], 3)).toHaveLength(3); + }); + + it('falls back to eight for a limit that is not a whole number in range', () => { + for (let i = 0; i < 12; i++) { + addListing({ createdAt: daysAgo(i) }); + } + for (const limit of [0, -1, 999, '8', 8.5, null, undefined, NaN]) { + expect(latest(['job-1'], limit), `limit ${String(limit)}`).toHaveLength(8); + } + }); + + it('carries the columns the panel renders', () => { + addListing({ title: 'Altbau mit Balkon' }); + const [row] = latest(['job-1']); + + expect(Object.keys(row).sort()).toEqual( + [ + 'address', + 'created_at', + 'id', + 'image_url', + 'job_id', + 'price', + 'price_per_sqm', + 'rooms', + 'size', + 'title', + ].sort(), + ); + expect(row.title).toBe('Altbau mit Balkon'); + }); + }); + + describe('getListingActivityPerJob', () => { + const activity = (jobIds, days) => listingsStorage.getListingActivityPerJob(jobIds, days, NOW); + + it('gives a job that found nothing a full series of zeroes rather than no row at all', () => { + const result = activity(['quiet-job']); + + expect(result['quiet-job']).toEqual({ perDay: [0, 0, 0, 0, 0, 0, 0], total: 0 }); + }); + + it('reports exactly seven days, the last of them today', () => { + addListing({ createdAt: daysAgo(0) }); + addListing({ createdAt: daysAgo(6) }); + addListing({ createdAt: daysAgo(7) }); + + const { perDay, total } = activity(['job-1'])['job-1']; + + expect(perDay).toHaveLength(7); + expect(perDay.at(-1)).toBe(1); + expect(perDay[0]).toBe(1); + expect(total).toBe(2); + }); + + it('counts each job on its own', () => { + addListing({ jobId: 'job-1', createdAt: daysAgo(0) }); + addListing({ jobId: 'job-1', createdAt: daysAgo(0) }); + addListing({ jobId: 'job-2', createdAt: daysAgo(1) }); + addListing({ jobId: 'not-asked-for', createdAt: daysAgo(1) }); + + const result = activity(['job-1', 'job-2']); + + expect(Object.keys(result).sort()).toEqual(['job-1', 'job-2']); + expect(result['job-1'].perDay.at(-1)).toBe(2); + expect(result['job-2'].perDay.at(-2)).toBe(1); + expect(result['job-1'].total).toBe(2); + }); + + it('ignores soft-deleted listings', () => { + addListing({ createdAt: daysAgo(1) }); + addListing({ createdAt: daysAgo(1), manuallyDeleted: 1 }); + + expect(activity(['job-1'])['job-1'].total).toBe(1); + }); + + it('returns nothing without querying when there is no job to read', () => { + addListing({}); + expect(activity([])).toEqual({}); + expect(activity(null)).toEqual({}); + }); + + it('honours a different span and falls back to seven for a nonsensical one', () => { + expect(activity(['job-1'], 14)['job-1'].perDay).toHaveLength(14); + expect(activity(['job-1'], 0)['job-1'].perDay).toHaveLength(7); + expect(activity(['job-1'], -3)['job-1'].perDay).toHaveLength(7); + }); + }); +}); diff --git a/test/services/jobs/dashboardRouter.test.js b/test/services/jobs/dashboardRouter.test.js index 2c256a7e..db4c4023 100644 --- a/test/services/jobs/dashboardRouter.test.js +++ b/test/services/jobs/dashboardRouter.test.js @@ -26,6 +26,14 @@ describe('api/routes/dashboardRouter.js', () => { getListingsKpisForJobIds: () => ({ numberOfActiveListings: 0, medianPriceOfListings: 0 }), getProviderDistributionForJobIds: () => [], getListingsPerDayForJobIds: () => [], + getLatestListingsForJobIds: (jobIds, limit) => { + state.latestCall = { jobIds, limit }; + return state.latest; + }, + getListingActivityPerJob: (jobIds, days) => { + state.activityCall = { jobIds, days }; + return state.jobActivity; + }, })); vi.doMock(settingsStoragePath, () => ({ getSettings: async () => ({ interval: 30 }), @@ -53,6 +61,8 @@ describe('api/routes/dashboardRouter.js', () => { currentUser: 'u1', admin: false, jobs: [], + latest: [], + jobActivity: {}, }; }); @@ -111,4 +121,23 @@ describe('api/routes/dashboardRouter.js', () => { expect(body.general.lastRun).toBeNull(); expect(body.general.nextRun).toBe(0); }); + + it('serves the newest listings and the per-job activity for the accessible jobs only', async () => { + state.jobs = [ + { id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: 1000 }, + { id: 'b', userId: 'someone-else', shared_with_user: [], lastRunAt: 2000 }, + ]; + state.latest = [{ id: 'l1', title: 'Altbau mit Balkon' }]; + state.jobActivity = { a: { perDay: [0, 0, 0, 0, 0, 0, 1], total: 1 } }; + app = await buildApp(); + + const body = (await app.inject({ method: 'GET', url: '/api/dashboard/' })).json(); + + expect(body.latest).toEqual(state.latest); + expect(body.jobActivity).toEqual(state.jobActivity); + // Both panels read through the same job scope as every other figure on the page, which is the + // only thing keeping another user's findings off this one's dashboard. + expect(state.latestCall).toEqual({ jobIds: ['a'], limit: 8 }); + expect(state.activityCall).toEqual({ jobIds: ['a'], days: 7 }); + }); }); diff --git a/test/ui/dashboardLayout.test.js b/test/ui/dashboardLayout.test.js new file mode 100644 index 00000000..413fbc1e --- /dev/null +++ b/test/ui/dashboardLayout.test.js @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const less = fs.readFileSync(path.join(root, 'ui/src/views/dashboard/Dashboard.less'), 'utf-8'); +const jsx = fs.readFileSync(path.join(root, 'ui/src/views/dashboard/Dashboard.jsx'), 'utf-8'); + +/** + * Spacing values a rule is allowed to carry. Anything else is a number someone typed instead of + * reaching for the scale, which is exactly how a layout drifts away from its spec one rule at a + * time. + */ +const ALLOWED_SPACING = /^(0|auto|inherit|@space-\d+|@dash-[a-z-]+)$/; + +const SPACING_PROPERTIES = + /(^|\s)(gap|row-gap|column-gap|margin|margin-top|margin-right|margin-bottom|margin-left|padding|padding-top|padding-right|padding-bottom|padding-left)\s*:\s*([^;{}]+);/g; + +describe('Dashboard.less', () => { + it('takes every spacing value from the scale, never a bare number', () => { + const offenders = []; + for (const match of less.matchAll(SPACING_PROPERTIES)) { + const property = match[2]; + // `!important` is a weight, not a length: it says who wins the cascade and nothing about + // the scale. Left in, it would fail `margin: 0 !important` for carrying a value that is not + // a spacing token, which is the one thing that line does not do. + const value = match[3].replace(/!important/g, '').trim(); + const parts = value.split(/\s+/); + for (const part of parts) { + if (!ALLOWED_SPACING.test(part)) { + offenders.push(`${property}: ${value}`); + break; + } + } + } + expect(offenders).toEqual([]); + }); + + it('lays the tiles out as four equal columns', () => { + expect(less).toMatch(/&__kpis\s*\{[\s\S]*?grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)/); + expect(less).toMatch(/&__kpis\s*\{[\s\S]*?gap:\s*@space-4/); + }); + + it('keeps the main column fluid and only the rail fixed', () => { + expect(less).toMatch(/&__grid\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0,\s*1fr\)\s+@dash-rail/); + expect(less).toMatch(/&__grid\s*\{[\s\S]*?gap:\s*@space-6/); + expect(less).toMatch(/@dash-rail:\s*400px/); + }); + + it('draws one card shell for the whole page', () => { + expect(less).toMatch(/&__card\s*\{[\s\S]*?background:\s*@color-surface/); + expect(less).toMatch(/&__card\s*\{[\s\S]*?border:\s*1px solid @color-border/); + expect(less).toMatch(/&__card\s*\{[\s\S]*?border-radius:\s*@radius-card/); + expect(less).toMatch(/&__card\s*\{[\s\S]*?padding:\s*@dash-card-pad/); + expect(less).toMatch(/@dash-card-pad:\s*@space-5/); + }); + + it('gives both directions of the weekly change the same weight', () => { + expect(less).toMatch(/&--up\s*\{\s*color:\s*@color-success/); + expect(less).toMatch(/&--down\s*\{\s*color:\s*@color-error/); + }); + + it('aligns the three right-hand columns of the listing rows', () => { + expect(less).toMatch(/&__latestPrice\s*\{[\s\S]*?width:\s*92px/); + expect(less).toMatch(/&__latestSqm\s*\{[\s\S]*?width:\s*108px/); + expect(less).toMatch(/&__latestAge\s*\{[\s\S]*?width:\s*74px/); + }); + + it('breaks to one column at the width the plan names', () => { + expect(less).toMatch(/@dash-stack:\s*1180px/); + expect(less).toMatch(/@media \(max-width: @dash-stack\)/); + }); + + it('has dropped the two wrappers the old layout was built from', () => { + // Narrowed from /__panel/ to the class itself: the stylesheet's own comment explains what + // replaced the old wrapper and names it, and a test that cannot tell a selector from the + // sentence describing its removal forbids the explanation along with the thing. + expect(less).not.toMatch(/dashboard__panel/); + expect(less).not.toMatch(/__section-label/); + }); +}); + +describe('Dashboard.jsx', () => { + it('spends no colour on a tile, because none of them mean anything', () => { + expect(jsx).not.toMatch(/color="(blue|orange|green|purple)"/); + expect(jsx.match(/color="plain"/g) ?? []).toHaveLength(4); + }); + + it('no longer lays the tiles out with the grid that floated them', () => { + expect(jsx).not.toMatch(/ { + expect(jsx).toMatch(/sampleSize/); + }); + + it('states no percentage when there is no previous week to compare against', () => { + expect(jsx).toMatch(/changePct == null/); + }); +}); diff --git a/ui/src/components/cards/DashboardCard.less b/ui/src/components/cards/DashboardCard.less index 91f30ac0..d917c675 100644 --- a/ui/src/components/cards/DashboardCard.less +++ b/ui/src/components/cards/DashboardCard.less @@ -80,9 +80,9 @@ &__value { font-size: 1.2rem; font-weight: 700; - margin-bottom: 4px; + margin-bottom: @space-1; line-height: 1.25; - color: var(--card-accent, @color-text); + color: var(--card-value, @color-text); } &__desc { @@ -93,22 +93,35 @@ &.blue { --card-accent: @color-blue-text; --card-glow: @color-blue-border; + --card-value: @color-blue-text; } &.orange { --card-accent: @color-orange-text; --card-glow: @color-orange-border; + --card-value: @color-orange-text; } &.green { --card-accent: @color-green-text; --card-glow: @color-green-border; + --card-value: @color-green-text; } &.purple { --card-accent: @color-purple-text; --card-glow: @color-purple-border; + --card-value: @color-purple-text; } &.gray { --card-accent: @color-gray-text; --card-glow: @color-gray-border; + --card-value: @color-gray-text; + } + + /* Die Variante fuer das Dashboard. Vier Kacheln nebeneinander, deren Farben nichts bedeuten, + lesen sich als Kategorien, die es nicht gibt. Hier traegt nur die Zahl, und Farbe bleibt der + Primaeraktion, der Richtung einer Veraenderung und dem Handlungsbedarf vorbehalten. */ + &.plain { + --card-accent: @color-border-bright; + --card-glow: @color-border-bright; } } diff --git a/ui/src/components/cards/KpiCard.jsx b/ui/src/components/cards/KpiCard.jsx index 3fbcd3d8..61c7cc2d 100644 --- a/ui/src/components/cards/KpiCard.jsx +++ b/ui/src/components/cards/KpiCard.jsx @@ -21,8 +21,8 @@ import './DashboardCard.less'; * @param {string} props.title * @param {React.ReactNode} [props.icon] * @param {React.ReactNode} props.value - * @param {string} [props.description] - * @param {string} [props.color] + * @param {React.ReactNode} [props.description] + * @param {'blue'|'orange'|'green'|'purple'|'gray'|'plain'} [props.color] * @param {() => void} [props.onClick] Makes the card a link to what it counts. * @param {React.ReactNode} [props.children] * @returns {React.ReactElement} diff --git a/ui/src/components/cards/TrendSparkline.jsx b/ui/src/components/cards/TrendSparkline.jsx deleted file mode 100644 index 5e5f501d..00000000 --- a/ui/src/components/cards/TrendSparkline.jsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) 2026 by Christian Kellner. - * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause - */ - -import React from 'react'; -import { Line } from 'react-chartjs-2'; - -import { CHART_COLORS, makeAreaGradient, registerFinanceCharts, withAlpha } from './chartTheme.js'; - -registerFinanceCharts(); - -/** - * How many listings came in per day, as a bare sparkline. - * - * No axes, no grid, no legend: at this size those would cost more room than the shape is worth, - * and the exact figures live in the KPI beside it. The only interactive part is the tooltip, - * which names the day, because "when was that spike" is the one question the shape provokes. - * - * @param {Object} props - * @param {Array<{date: string, count: number}>} props.data Oldest first. - * @param {string} [props.locale] - * @param {number} [props.height=44] - */ -export default function TrendSparkline({ data = [], locale = 'de-DE', height = 44 }) { - const rows = React.useMemo(() => (Array.isArray(data) ? data : []), [data]); - - const chartData = React.useMemo( - () => ({ - labels: rows.map((row) => row.date), - datasets: [ - { - data: rows.map((row) => Number(row.count) || 0), - borderColor: CHART_COLORS.ACCENT, - borderWidth: 2, - pointRadius: 0, - pointHoverRadius: 3, - pointHoverBackgroundColor: CHART_COLORS.ACCENT, - tension: 0.35, - fill: true, - backgroundColor: (ctx) => { - const { chart } = ctx; - if (!chart.chartArea) { - return withAlpha(CHART_COLORS.ACCENT, 0.15); - } - return makeAreaGradient(chart.ctx, chart.chartArea, CHART_COLORS.ACCENT, 0.28); - }, - }, - ], - }), - [rows], - ); - - const options = React.useMemo( - () => ({ - responsive: true, - maintainAspectRatio: false, - // The dashboard reloads on every visit; replaying a draw-on animation each time turns a - // background statistic into something that demands attention. - animation: false, - layout: { padding: 0 }, - scales: { x: { display: false }, y: { display: false, beginAtZero: true } }, - plugins: { - legend: { display: false }, - tooltip: { - displayColors: false, - callbacks: { - title: (items) => new Date(items[0].label).toLocaleDateString(locale), - label: (ctx) => `${ctx.parsed.y}`, - }, - }, - }, - interaction: { mode: 'index', intersect: false }, - }), - [locale], - ); - - if (rows.length === 0) { - return null; - } - - return ( -
- -
- ); -} - -TrendSparkline.displayName = 'TrendSparkline'; diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index 06cab983..03902692 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -34,21 +34,32 @@ "dashboard.attention.nothingFound": "„{{name}}“ hat bisher nichts gefunden.", "dashboard.attentionFix": "Öffnen", "dashboard.attentionMore": "und {{count}} weitere", - "dashboard.sectionProviderInsights": "Anbieter-Einblicke", + "dashboard.sectionProviderInsights": "Portale", + "dashboard.sectionLatest": "Neueste Treffer", + "dashboard.latestAll": "Alle {{count}} Inserate", + "dashboard.latestEmpty": "Noch keine Treffer", + "dashboard.sectionJobs": "Suchaufträge", + "dashboard.jobsManage": "Suchaufträge verwalten", + "dashboard.jobLastRun": "zuletzt {{time}}", + "dashboard.jobNoHits": "zuletzt {{time}}, ohne Treffer", + "dashboard.kpiNew7": "Neu in 7 Tagen", + "dashboard.kpiNew7NoCompare": "keine Vorwoche zum Vergleich", + "dashboard.deltaVsPreviousWeek": "{{percent}} % zur Vorwoche", + "dashboard.trendPreviousWeek": "Vorwoche {{count}}", + "dashboard.trendCurrentWeek": "Diese Woche {{count}}", + "dashboard.trendAria": "Neue Inserate pro Tag über 14 Tage. Vorwoche {{previous}}, diese Woche {{current}}.", "dashboard.searchNowButton": "Jetzt suchen", "dashboard.searchNowStarted": "Fredy-Suche erfolgreich gestartet.", "dashboard.searchNowFailed": "Suche konnte nicht gestartet werden", "dashboard.priceTrackerButton": "Preis-Tracker jetzt starten", "dashboard.priceTrackerStarted": "Preis-Tracker gestartet. Das kann eine Weile dauern.", "dashboard.priceTrackerFailed": "Preis-Tracker konnte nicht gestartet werden", - "dashboard.kpiJobs": "Jobs", - "dashboard.kpiJobsDesc": "Gesamtanzahl der Jobs", - "dashboard.kpiListings": "Inserate", + "dashboard.kpiListings": "Aktive Inserate", "dashboard.kpiMedianPrice": "Medianpreis", - "dashboard.kpiMedianPriceDesc": "Medianpreis der Inserate", + "dashboard.kpiMedianPriceDesc": "über {{count}} Inserate, inklusive inaktiver", "dashboard.kpiMedianSqm": "Preis pro m²", - "dashboard.kpiMedianSqmDesc.rent": "Median über {{count}} Mietinserate", - "dashboard.kpiMedianSqmDesc.buy": "Median über {{count}} Kaufinserate", + "dashboard.kpiMedianSqmDesc.rent": "Miete, dieselben {{count}} Inserate", + "dashboard.kpiMedianSqmDesc.buy": "Kauf, dieselben {{count}} Inserate", "dashboard.kpiMedianSqmPending": "Wartet auf die ersten Inserate mit Wohnfläche", "dashboard.timeNow": "gerade eben", "dashboard.timeInMinutes": "in {{count}} Min", @@ -57,10 +68,8 @@ "dashboard.timeAgoHours": "vor {{count}} Std", "dashboard.timeInDays": "in {{count}} Tagen", "dashboard.timeAgoDays": "vor {{count}} Tagen", - "dashboard.kpiListingsActiveDesc": "Gefundene Inserate, davon {{active}} aktiv", - "dashboard.sectionTrend": "Verlauf", - "dashboard.trendThisWeek": "neue Inserate in den letzten 7 Tagen", - "dashboard.trendVsPreviousWeek": "ggü. Vorwoche", + "dashboard.kpiListingsActiveDesc": "von {{total}} je gefundenen", + "dashboard.sectionTrend": "Neu pro Tag, 14 Tage", "dashboard.providerBelowOnePercent": "< 1 %", "dashboard.providerSingle": "Alle {{count}} Inserate kommen von {{provider}}.", "dashboard.emptyTitle": "Noch kein Job angelegt", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index 0f48b966..27072a2d 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -34,21 +34,32 @@ "dashboard.attention.nothingFound": "\"{{name}}\" has not found anything yet.", "dashboard.attentionFix": "Open", "dashboard.attentionMore": "and {{count}} more", - "dashboard.sectionProviderInsights": "Provider Insights", + "dashboard.sectionProviderInsights": "Portals", + "dashboard.sectionLatest": "Newest hits", + "dashboard.latestAll": "All {{count}} listings", + "dashboard.latestEmpty": "Nothing found yet", + "dashboard.sectionJobs": "Searches", + "dashboard.jobsManage": "Manage searches", + "dashboard.jobLastRun": "last run {{time}}", + "dashboard.jobNoHits": "last run {{time}}, nothing found", + "dashboard.kpiNew7": "New in 7 days", + "dashboard.kpiNew7NoCompare": "no previous week to compare against", + "dashboard.deltaVsPreviousWeek": "{{percent}} % vs previous week", + "dashboard.trendPreviousWeek": "Previous week {{count}}", + "dashboard.trendCurrentWeek": "This week {{count}}", + "dashboard.trendAria": "New listings per day over 14 days. Previous week {{previous}}, this week {{current}}.", "dashboard.searchNowButton": "Search now", "dashboard.searchNowStarted": "Successfully triggered Fredy search.", "dashboard.searchNowFailed": "Failed to trigger search", "dashboard.priceTrackerButton": "Start price tracker now", "dashboard.priceTrackerStarted": "Price tracker started. This may take a while.", "dashboard.priceTrackerFailed": "Failed to start the price tracker", - "dashboard.kpiJobs": "Jobs", - "dashboard.kpiJobsDesc": "Total number of jobs", - "dashboard.kpiListings": "Listings", + "dashboard.kpiListings": "Active listings", "dashboard.kpiMedianPrice": "Median Price", - "dashboard.kpiMedianPriceDesc": "Median Price of listings", + "dashboard.kpiMedianPriceDesc": "across {{count}} listings, inactive ones included", "dashboard.kpiMedianSqm": "Price per m²", - "dashboard.kpiMedianSqmDesc.rent": "Median across {{count}} listings to rent", - "dashboard.kpiMedianSqmDesc.buy": "Median across {{count}} listings to buy", + "dashboard.kpiMedianSqmDesc.rent": "Rent, the same {{count}} listings", + "dashboard.kpiMedianSqmDesc.buy": "Purchase, the same {{count}} listings", "dashboard.kpiMedianSqmPending": "Waiting for the first listings that state a size", "dashboard.timeNow": "just now", "dashboard.timeInMinutes": "in {{count}} min", @@ -57,10 +68,8 @@ "dashboard.timeAgoHours": "{{count}} h ago", "dashboard.timeInDays": "in {{count}} days", "dashboard.timeAgoDays": "{{count}} days ago", - "dashboard.kpiListingsActiveDesc": "Listings found, {{active}} of them active", - "dashboard.sectionTrend": "Trend", - "dashboard.trendThisWeek": "new listings in the last 7 days", - "dashboard.trendVsPreviousWeek": "vs previous week", + "dashboard.kpiListingsActiveDesc": "of {{total}} ever found", + "dashboard.sectionTrend": "New per day, 14 days", "dashboard.providerBelowOnePercent": "< 1 %", "dashboard.providerSingle": "All {{count}} listings come from {{provider}}.", "dashboard.emptyTitle": "No job yet", diff --git a/ui/src/locales/es.json b/ui/src/locales/es.json index af16460a..f89f5731 100644 --- a/ui/src/locales/es.json +++ b/ui/src/locales/es.json @@ -35,20 +35,31 @@ "dashboard.attentionFix": "Abrir", "dashboard.attentionMore": "y {{count}} más", "dashboard.sectionProviderInsights": "Portales", + "dashboard.sectionLatest": "Últimos resultados", + "dashboard.latestAll": "Los {{count}} anuncios", + "dashboard.latestEmpty": "Todavía sin resultados", + "dashboard.sectionJobs": "Búsquedas", + "dashboard.jobsManage": "Gestionar búsquedas", + "dashboard.jobLastRun": "última ejecución {{time}}", + "dashboard.jobNoHits": "última ejecución {{time}}, sin resultados", + "dashboard.kpiNew7": "Nuevos en 7 días", + "dashboard.kpiNew7NoCompare": "sin semana previa para comparar", + "dashboard.deltaVsPreviousWeek": "{{percent}} % frente a la semana anterior", + "dashboard.trendPreviousWeek": "Semana anterior {{count}}", + "dashboard.trendCurrentWeek": "Esta semana {{count}}", + "dashboard.trendAria": "Anuncios nuevos por día durante 14 días. Semana anterior {{previous}}, esta semana {{current}}.", "dashboard.searchNowButton": "Buscar ahora", "dashboard.searchNowStarted": "Búsqueda de Fredy iniciada.", "dashboard.searchNowFailed": "No se ha podido iniciar la búsqueda", "dashboard.priceTrackerButton": "Revisar precios ahora", "dashboard.priceTrackerStarted": "Revisión de precios iniciada. Puede tardar un rato.", "dashboard.priceTrackerFailed": "No se ha podido iniciar la revisión de precios", - "dashboard.kpiJobs": "Búsquedas", - "dashboard.kpiJobsDesc": "Número total de búsquedas", - "dashboard.kpiListings": "Anuncios", + "dashboard.kpiListings": "Anuncios activos", "dashboard.kpiMedianPrice": "Precio mediano", - "dashboard.kpiMedianPriceDesc": "Precio mediano de los anuncios", + "dashboard.kpiMedianPriceDesc": "sobre {{count}} anuncios, inactivos incluidos", "dashboard.kpiMedianSqm": "Precio por m²", - "dashboard.kpiMedianSqmDesc.rent": "Mediana de {{count}} anuncios de alquiler", - "dashboard.kpiMedianSqmDesc.buy": "Mediana de {{count}} anuncios de venta", + "dashboard.kpiMedianSqmDesc.rent": "Alquiler, los mismos {{count}} anuncios", + "dashboard.kpiMedianSqmDesc.buy": "Venta, los mismos {{count}} anuncios", "dashboard.kpiMedianSqmPending": "Esperando a los primeros anuncios que indiquen superficie", "dashboard.timeNow": "ahora mismo", "dashboard.timeInMinutes": "en {{count}} min", @@ -57,10 +68,8 @@ "dashboard.timeAgoHours": "hace {{count}} h", "dashboard.timeInDays": "en {{count}} días", "dashboard.timeAgoDays": "hace {{count}} días", - "dashboard.kpiListingsActiveDesc": "Anuncios encontrados, {{active}} de ellos activos", - "dashboard.sectionTrend": "Tendencia", - "dashboard.trendThisWeek": "anuncios nuevos en los últimos 7 días", - "dashboard.trendVsPreviousWeek": "frente a la semana anterior", + "dashboard.kpiListingsActiveDesc": "de {{total}} encontrados en total", + "dashboard.sectionTrend": "Nuevos por día, 14 días", "dashboard.providerBelowOnePercent": "< 1 %", "dashboard.providerSingle": "Los {{count}} anuncios vienen de {{provider}}.", "dashboard.emptyTitle": "Todavía no hay búsquedas", diff --git a/ui/src/locales/it.json b/ui/src/locales/it.json index 80dc2219..038a04c9 100644 --- a/ui/src/locales/it.json +++ b/ui/src/locales/it.json @@ -35,20 +35,31 @@ "dashboard.attentionFix": "Apri", "dashboard.attentionMore": "e altri {{count}}", "dashboard.sectionProviderInsights": "Portali", + "dashboard.sectionLatest": "Ultimi risultati", + "dashboard.latestAll": "Tutti i {{count}} annunci", + "dashboard.latestEmpty": "Ancora nessun risultato", + "dashboard.sectionJobs": "Ricerche", + "dashboard.jobsManage": "Gestisci ricerche", + "dashboard.jobLastRun": "ultima esecuzione {{time}}", + "dashboard.jobNoHits": "ultima esecuzione {{time}}, nessun risultato", + "dashboard.kpiNew7": "Nuovi in 7 giorni", + "dashboard.kpiNew7NoCompare": "nessuna settimana precedente da confrontare", + "dashboard.deltaVsPreviousWeek": "{{percent}} % rispetto alla settimana scorsa", + "dashboard.trendPreviousWeek": "Settimana scorsa {{count}}", + "dashboard.trendCurrentWeek": "Questa settimana {{count}}", + "dashboard.trendAria": "Nuovi annunci al giorno su 14 giorni. Settimana scorsa {{previous}}, questa settimana {{current}}.", "dashboard.searchNowButton": "Cerca ora", "dashboard.searchNowStarted": "Ricerca di Fredy avviata.", "dashboard.searchNowFailed": "Impossibile avviare la ricerca", "dashboard.priceTrackerButton": "Controlla i prezzi ora", "dashboard.priceTrackerStarted": "Controllo dei prezzi avviato. Può richiedere un po' di tempo.", "dashboard.priceTrackerFailed": "Impossibile avviare il controllo dei prezzi", - "dashboard.kpiJobs": "Ricerche", - "dashboard.kpiJobsDesc": "Numero totale di ricerche", - "dashboard.kpiListings": "Annunci", + "dashboard.kpiListings": "Annunci attivi", "dashboard.kpiMedianPrice": "Prezzo mediano", - "dashboard.kpiMedianPriceDesc": "Prezzo mediano degli annunci", + "dashboard.kpiMedianPriceDesc": "su {{count}} annunci, inattivi inclusi", "dashboard.kpiMedianSqm": "Prezzo al m²", - "dashboard.kpiMedianSqmDesc.rent": "Mediana su {{count}} annunci in affitto", - "dashboard.kpiMedianSqmDesc.buy": "Mediana su {{count}} annunci in vendita", + "dashboard.kpiMedianSqmDesc.rent": "Affitto, gli stessi {{count}} annunci", + "dashboard.kpiMedianSqmDesc.buy": "Vendita, gli stessi {{count}} annunci", "dashboard.kpiMedianSqmPending": "In attesa dei primi annunci che indichino la superficie", "dashboard.timeNow": "proprio ora", "dashboard.timeInMinutes": "tra {{count}} min", @@ -57,10 +68,8 @@ "dashboard.timeAgoHours": "{{count}} h fa", "dashboard.timeInDays": "tra {{count}} giorni", "dashboard.timeAgoDays": "{{count}} giorni fa", - "dashboard.kpiListingsActiveDesc": "Annunci trovati, di cui {{active}} attivi", - "dashboard.sectionTrend": "Andamento", - "dashboard.trendThisWeek": "nuovi annunci negli ultimi 7 giorni", - "dashboard.trendVsPreviousWeek": "rispetto alla settimana precedente", + "dashboard.kpiListingsActiveDesc": "su {{total}} trovati in totale", + "dashboard.sectionTrend": "Nuovi al giorno, 14 giorni", "dashboard.providerBelowOnePercent": "< 1 %", "dashboard.providerSingle": "Tutti i {{count}} annunci provengono da {{provider}}.", "dashboard.emptyTitle": "Ancora nessuna ricerca", diff --git a/ui/src/locales/tr.json b/ui/src/locales/tr.json index dd799967..323038b2 100644 --- a/ui/src/locales/tr.json +++ b/ui/src/locales/tr.json @@ -34,21 +34,32 @@ "dashboard.attention.nothingFound": "\"{{name}}\" henüz bir şey bulmadı.", "dashboard.attentionFix": "Aç", "dashboard.attentionMore": "ve {{count}} tane daha", - "dashboard.sectionProviderInsights": "Sağlayıcı İçgörüleri", + "dashboard.sectionProviderInsights": "Portallar", + "dashboard.sectionLatest": "En yeni sonuçlar", + "dashboard.latestAll": "Tüm {{count}} ilan", + "dashboard.latestEmpty": "Henüz sonuç yok", + "dashboard.sectionJobs": "İşler", + "dashboard.jobsManage": "İşleri yönet", + "dashboard.jobLastRun": "son çalışma {{time}}", + "dashboard.jobNoHits": "son çalışma {{time}}, sonuç yok", + "dashboard.kpiNew7": "7 günde yeni", + "dashboard.kpiNew7NoCompare": "karşılaştırılacak önceki hafta yok", + "dashboard.deltaVsPreviousWeek": "önceki haftaya göre %{{percent}}", + "dashboard.trendPreviousWeek": "Önceki hafta {{count}}", + "dashboard.trendCurrentWeek": "Bu hafta {{count}}", + "dashboard.trendAria": "14 gün boyunca günlük yeni ilanlar. Önceki hafta {{previous}}, bu hafta {{current}}.", "dashboard.searchNowButton": "Şimdi ara", "dashboard.searchNowStarted": "Fredy araması başarıyla tetiklendi.", "dashboard.searchNowFailed": "Arama tetiklenemedi", "dashboard.priceTrackerButton": "Fiyat takibini şimdi başlat", "dashboard.priceTrackerStarted": "Fiyat takibi başlatıldı. Bu biraz zaman alabilir.", "dashboard.priceTrackerFailed": "Fiyat takibi başlatılamadı", - "dashboard.kpiJobs": "İşler", - "dashboard.kpiJobsDesc": "Toplam iş sayısı", - "dashboard.kpiListings": "İlanlar", + "dashboard.kpiListings": "Etkin ilanlar", "dashboard.kpiMedianPrice": "Ortanca Fiyat", - "dashboard.kpiMedianPriceDesc": "İlanların ortanca fiyatı", + "dashboard.kpiMedianPriceDesc": "etkin olmayanlar dahil {{count}} ilan üzerinden", "dashboard.kpiMedianSqm": "m² fiyatı", - "dashboard.kpiMedianSqmDesc.rent": "{{count}} kiralık ilanın medyanı", - "dashboard.kpiMedianSqmDesc.buy": "{{count}} satılık ilanın medyanı", + "dashboard.kpiMedianSqmDesc.rent": "Kiralık, aynı {{count}} ilan", + "dashboard.kpiMedianSqmDesc.buy": "Satılık, aynı {{count}} ilan", "dashboard.kpiMedianSqmPending": "Yaşam alanı belirten ilk ilanlar bekleniyor", "dashboard.timeNow": "az önce", "dashboard.timeInMinutes": "{{count}} dk içinde", @@ -57,10 +68,8 @@ "dashboard.timeAgoHours": "{{count}} sa önce", "dashboard.timeInDays": "{{count}} gün içinde", "dashboard.timeAgoDays": "{{count}} gün önce", - "dashboard.kpiListingsActiveDesc": "{{active}} ilan aktif, toplam bulunan ilan sayısı", - "dashboard.sectionTrend": "Eğilim", - "dashboard.trendThisWeek": "son 7 gündeki yeni ilanlar", - "dashboard.trendVsPreviousWeek": "önceki haftaya kıyasla", + "dashboard.kpiListingsActiveDesc": "bulunan toplam {{total}} ilandan", + "dashboard.sectionTrend": "Günlük yeni, 14 gün", "dashboard.providerBelowOnePercent": "< %1", "dashboard.providerSingle": "{{count}} ilanın tümü {{provider}} kaynaklıdır.", "dashboard.emptyTitle": "Henüz iş yok", diff --git a/ui/src/themes.less b/ui/src/themes.less index 19481b7b..a3287b48 100644 --- a/ui/src/themes.less +++ b/ui/src/themes.less @@ -53,6 +53,11 @@ --f-accent-dim: #a63b2b; --f-accent-glow: rgb(198 74 55 / 10%); + // Der Akzent als Textfarbe. #c64a37 liegt als Text bei 3,97:1 auf --f-base und reisst damit AA; + // als Fläche mit weisser Schrift ist er mit 4,73:1 in Ordnung und bleibt unveraendert. Dieser + // Schritt ist derselbe Farbton, zwei Stufen heller: 5,40:1 auf --f-base, 5,14:1 auf --f-surface. + --f-accent-text: #d9664f; + // Text. Warm off-white rather than grey-white: pure neutral text on a warm ground reads blue. --f-text: #f2ede8; --f-muted: #9c918a; @@ -153,6 +158,9 @@ body[theme-mode='light'] { --f-accent-dim: #953a28; --f-accent-glow: rgb(176 68 47 / 8%); + // Auf Papier traegt der Akzent selbst als Text: 4,93:1 auf --f-base, 5,38:1 auf --f-surface. + --f-accent-text: #b0442f; + --f-text: #1d1916; --f-muted: #645d56; --f-faint: #958c83; diff --git a/ui/src/tokens.less b/ui/src/tokens.less index 682ac7f8..7bb073b5 100644 --- a/ui/src/tokens.less +++ b/ui/src/tokens.less @@ -31,6 +31,7 @@ @color-accent-rgb: var(--f-accent-rgb); @color-accent-dim: var(--f-accent-dim); @color-accent-glow: var(--f-accent-glow); +@color-accent-text: var(--f-accent-text); // Text @color-text: var(--f-text); diff --git a/ui/src/views/dashboard/Dashboard.jsx b/ui/src/views/dashboard/Dashboard.jsx index e66a26de..89291ac7 100644 --- a/ui/src/views/dashboard/Dashboard.jsx +++ b/ui/src/views/dashboard/Dashboard.jsx @@ -4,16 +4,18 @@ */ import React from 'react'; -import { Button, Col, Row, Toast, Tooltip, Typography } from '@douyinfe/semi-ui-19'; +import { Button, Toast, Tooltip, Typography } from '@douyinfe/semi-ui-19'; import { useNavigate } from 'react-router'; import { - IconTerminal, IconClock, IconStarStroked, IconPlayCircle, IconPlusCircle, IconAlertTriangle, IconExpand, + IconArrowUp, + IconArrowDown, + IconArrowUpRight, } from '@douyinfe/semi-icons'; import { useSelector, useActions } from '../../services/state/store'; @@ -22,7 +24,9 @@ import { findJobsNeedingAttention, countJobsNeedingAttention } from '../../servi import IconEuro from '../../components/icons/IconEuro.jsx'; import KpiCard from '../../components/cards/KpiCard.jsx'; import ProviderShareChart from '../../components/cards/ProviderShareChart.jsx'; -import TrendSparkline from '../../components/cards/TrendSparkline.jsx'; +import TrendBars from './components/TrendBars.jsx'; +import LatestListings from './components/LatestListings.jsx'; +import JobsPanel from './components/JobsPanel.jsx'; import Headline from '../../components/headline/Headline.jsx'; import './Dashboard.less'; @@ -31,40 +35,10 @@ import { formatEuroPrice } from '../../services/price/priceService.js'; import { formatPricePerSqm } from '../../services/listings/marketBenchmark.js'; import { format } from '../../services/time/timeService.js'; import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx'; +import { relativeTime } from './dashboardTime.js'; const { Text, Title } = Typography; -/** - * Turn a timestamp into how far away it is, e.g. "in 56 min" or "4 min ago". - * - * A job that runs on an interval makes an absolute timestamp work the reader has to do: they - * have to subtract the current time to learn the only thing they wanted, which is whether it - * just ran or is about to. The exact stamp stays available in the tooltip. - * - * @param {number|null|undefined} timestamp Epoch ms. - * @param {(key: string, params?: Object) => string} t - * @param {number} [now] - * @returns {string|null} `null` when there is nothing to describe. - */ -function relativeTime(timestamp, t, now = Date.now()) { - if (timestamp == null || timestamp === 0) { - return null; - } - const deltaMinutes = Math.round((timestamp - now) / 60000); - const magnitude = Math.abs(deltaMinutes); - if (magnitude < 1) { - return t('dashboard.timeNow'); - } - const unit = - magnitude < 60 - ? { key: 'Minutes', value: magnitude } - : magnitude < 60 * 24 - ? { key: 'Hours', value: Math.round(magnitude / 60) } - : { key: 'Days', value: Math.round(magnitude / (60 * 24)) }; - const direction = deltaMinutes > 0 ? 'in' : 'ago'; - return t(`dashboard.time${direction === 'in' ? 'In' : 'Ago'}${unit.key}`, { count: String(unit.value) }); -} - export default function Dashboard() { const t = useTranslation(); const locale = useLocale(); @@ -89,6 +63,8 @@ export default function Dashboard() { const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 }; const trend = dashboard?.trend; const providerShare = dashboard?.pie || []; + const latest = dashboard?.latest || []; + const jobActivity = dashboard?.jobActivity || {}; const lastRun = dashboard?.general?.lastRun; const nextRun = dashboard?.general?.nextRun; @@ -164,7 +140,10 @@ export default function Dashboard() { text={t('dashboard.title')} actions={
- {canRunPriceTracker && ( @@ -202,129 +181,152 @@ export default function Dashboard() {
{/* Every card here is a way into the thing it counts. They reported numbers and went - nowhere, which made the dashboard somewhere you pass through rather than start from. */} - - - } - description={t('dashboard.kpiJobsDesc')} - onClick={() => navigate('/jobs')} - /> - - - {/* One card, not two: the old pair reported the same number twice whenever nothing had - gone inactive yet, which is the normal case. */} - } - description={t('dashboard.kpiListingsActiveDesc', { - active: String(kpis.numberOfActiveListings ?? 0), - })} - onClick={() => navigate('/listings')} - /> - - - } - description={t('dashboard.kpiMedianPriceDesc')} - onClick={() => navigate('/listings?sort=price&dir=asc')} - /> - - - {/* The median price next door answers "what do flats cost here", which is a different - question from "what does a square metre cost here" - the first moves with how big - the flats a search happens to turn up are, the second does not. - One deal type only, named in the description: a median taken over rents and purchase - prices at once would describe neither. */} - } - description={ - kpis.medianPricePerSqm == null - ? t('dashboard.kpiMedianSqmPending') - : t(`dashboard.kpiMedianSqmDesc.${kpis.medianPricePerSqm.dealType}`, { - count: String(kpis.medianPricePerSqm.sampleSize), - }) - } - onClick={() => navigate('/listings')} - /> - - + nowhere, which made the dashboard somewhere you pass through rather than start from. + All four are plain: four colours across the most important row of the page suggested + four categories, and there are none. */} +
+ {/* One card, not two: the old pair reported the same number twice whenever nothing had + gone inactive yet, which is the normal case. */} + } + description={t('dashboard.kpiListingsActiveDesc', { + total: String(kpis.totalListings ?? 0), + })} + onClick={() => navigate('/listings')} + /> + {/* This replaced a card reading "Jobs 2". A standing count of searches is the one thing + the reader already knows; what came in this week is not. */} + } + description={ + trend?.changePct == null ? ( + t('dashboard.kpiNew7NoCompare') + ) : ( + + {trend.changePct < 0 ? : } + {t('dashboard.deltaVsPreviousWeek', { percent: String(Math.abs(trend.changePct)) })} + + ) + } + onClick={() => navigate('/listings')} + /> + } + // Both medians are taken over every listing that was ever found, active or not, while + // the first card counts only the active ones. Without the population named, two numbers + // that cannot be reconciled sit next to each other. + description={ + kpis.medianPricePerSqm == null + ? t('dashboard.kpiMedianSqmPending') + : t('dashboard.kpiMedianPriceDesc', { count: String(kpis.medianPricePerSqm.sampleSize) }) + } + onClick={() => navigate('/listings?sort=price&dir=asc')} + /> + {/* The median price next door answers "what do flats cost here", which is a different + question from "what does a square metre cost here" - the first moves with how big + the flats a search happens to turn up are, the second does not. + One deal type only, named in the description: a median taken over rents and purchase + prices at once would describe neither. */} + } + description={ + kpis.medianPricePerSqm == null + ? t('dashboard.kpiMedianSqmPending') + : t(`dashboard.kpiMedianSqmDesc.${kpis.medianPricePerSqm.dealType}`, { + count: String(kpis.medianPricePerSqm.sampleSize), + }) + } + onClick={() => navigate('/listings')} + /> +
{/* Only when there is something to say. A permanent panel reading "all good" is a panel people stop looking at, which defeats the point of having one. */} {attention.length > 0 && ( - <> -
{t('dashboard.sectionAttention')}
-
-
    - {attention.map((entry) => ( -
  • - - - {t(`dashboard.attention.${entry.reason}`, { name: entry.name })} - - -
  • - ))} -
- {attentionTotal > attention.length && ( - - {t('dashboard.attentionMore', { count: String(attentionTotal - attention.length) })} - - )} -
- +
+
    + {attention.map((entry) => ( +
  • + + + {t(`dashboard.attention.${entry.reason}`, { name: entry.name })} + + +
  • + ))} +
+ {attentionTotal > attention.length && ( + + {t('dashboard.attentionMore', { count: String(attentionTotal - attention.length) })} + + )} +
)} - {trend?.perDay?.length > 0 && ( - <> -
{t('dashboard.sectionTrend')}
-
-
-
- {trend.thisWeek} - - {t('dashboard.trendThisWeek')} - + {/* What came in on the left, the three things that describe the searches on the right. The + bottom half of this page used to be empty. */} +
+
+ navigate(`/listings/listing/${id}`)} + onOpenAll={() => navigate('/listings')} + /> +
+
+ {trend?.perDay?.length > 0 && ( +
+
+

{t('dashboard.sectionTrend')}

- {/* Only stated when there is a previous week to compare against. A jump from zero - is not a percentage, and pretending otherwise would read as real growth. */} - {trend.changePct != null && ( - - {trend.changePct > 0 ? '+' : ''} - {trend.changePct} % {t('dashboard.trendVsPreviousWeek')} - - )} +
- + )} + navigate('/jobs')} + onOpenJob={(id) => navigate(`/jobs/edit/${id}`)} + /> +
+
+

{t('dashboard.sectionProviderInsights')}

+
+
- - )} - -
{t('dashboard.sectionProviderInsights')}
-
- +
); diff --git a/ui/src/views/dashboard/Dashboard.less b/ui/src/views/dashboard/Dashboard.less index 34be7aec..94f3144b 100644 --- a/ui/src/views/dashboard/Dashboard.less +++ b/ui/src/views/dashboard/Dashboard.less @@ -1,13 +1,31 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + @import '../../tokens.less'; +/* Constants that are only true for this page. tokens.less carries the app-wide names and has to + stay free of rules, so a width that means something here and nowhere else lives here. */ +@dash-rail: 400px; +@dash-stack: 1180px; +@dash-narrow: 680px; +@dash-card-pad: @space-5; +@dash-listing-row: 64px; +@dash-job-row: 56px; +@dash-thumb-w: 52px; +@dash-thumb-h: 40px; +@dash-spark-w: 60px; +@dash-spark-h: 24px; +@dash-trend-h: 150px; + .dashboard { display: flex; flex-direction: column; flex: 1; // Two things you can set running from here. The rule between them says they are separate - // actions rather than one control with a dropdown, which is what two adjacent borderless - // buttons otherwise read as. + // actions rather than one control with a dropdown. &__actions { display: flex; align-items: center; @@ -17,74 +35,376 @@ &__actions-divider { width: 1px; align-self: stretch; - margin: 4px 0; + margin: @space-1 0; background: @color-border-bright; flex-shrink: 0; } - &__section-label { - display: block; + /* Four tiles, equal width, never floated. minmax(0, 1fr) rather than 1fr because a long figure + in one tile otherwise widens its column and the row stops being four equal things. */ + &__kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: @space-4; + } + + /* The change against the previous week, inside a tile's description. Both directions carry the + same weight: green for growth and grey for a collapse reads as "nothing happened" for the one + number on this page that should send someone to their job settings. */ + &__delta { + display: inline-flex; + align-items: center; + gap: @space-1; + font-weight: 600; + + &--up { + color: @color-success; + } + + &--down { + color: @color-error; + } + } + + /* Content left, context right. The main column is fluid because the app's navigation owns a + variable slice of the window; only the rail is fixed. */ + &__grid { + display: grid; + grid-template-columns: minmax(0, 1fr) @dash-rail; + gap: @space-6; + align-items: start; + margin-top: @space-6; + } + + &__main, + &__rail { + display: flex; + flex-direction: column; + gap: @space-6; + min-width: 0; + } + + /* One card shell for the whole page. The old __panel and the KPI cards drew two different + cards, which is why the page read as two products stacked. */ + &__card { + background: @color-surface; + border: 1px solid @color-border; + border-radius: @radius-card; + padding: @dash-card-pad; + } + + &__cardHead { + display: flex; + align-items: center; + gap: @space-3; + min-height: 24px; + margin-bottom: @space-3; + } + + &__cardLabel { + margin: 0; font-size: @text-xs; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; - color: @color-faint; - margin-bottom: 10px; - margin-top: 1.5rem; + color: @color-muted; } - /* Same reason as the finance KPI grid: Semi's columns float, and floats of unequal height - stagger instead of wrapping cleanly once a card is allowed to grow with its content. */ - &__row { - display: flex; - flex-wrap: wrap; - align-items: stretch; - margin-bottom: 8px; + &__spacer { + flex: 1 1 auto; + } - > .semi-col { - float: none; - display: flex; - min-width: 0; + &__count { + font-family: @font-mono; + font-size: @text-sm; + font-weight: 700; + color: @color-text; + } + + /* A link that is really a button, so it is reachable by keyboard and announced as an action. + The accent is too dark to carry small text on this background, so the underline does the + work and the accent appears on hover, where legibility is no longer the question. */ + &__link { + padding: 0; + border: none; + background: none; + font-family: @font-ui; + font-size: @text-sm; + font-weight: 500; + color: @color-text; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; + + &:hover { + color: @color-accent-text; } } - /* Same surface and border as every other card in the app. The old wrapper hard-coded its own - two greys, which read as a panel from a different product, and reserved 320px of height for - a chart that now needs a fraction of it. */ - &__panel { - background: @color-surface; + &__chip { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 @space-2; border: 1px solid @color-border; - border-radius: @radius-card; - padding: @space-5; - margin-bottom: @space-2; + border-radius: @radius-chip; + background: @color-hover; + font-family: @font-mono; + font-size: @text-xs; + color: @color-muted; + white-space: nowrap; } - &__trend-header { + /* Newest hits. The reason anyone opens this page, and it used to be one navigation away. */ + &__latest { display: flex; - align-items: baseline; - justify-content: space-between; + flex-direction: column; + } + + &__latestRow { + display: flex; + align-items: center; gap: @space-3; - flex-wrap: wrap; + width: 100%; + height: @dash-listing-row; + padding: 0; + border: none; + border-top: 1px solid @color-border; + background: none; + font-family: @font-ui; + text-align: left; + cursor: pointer; + transition: background @transition-fast; + + &:hover { + background: @color-hover; + } + } + + /* Two layers. The drawn house sits underneath and the photo covers it; a photo that fails to + load hides itself and uncovers the house again, which is why this needs no state. */ + &__thumb { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: @dash-thumb-w; + height: @dash-thumb-h; + border-radius: @radius-chip; + background: @color-elevated; + color: @color-faint; + overflow: hidden; + + svg { + width: 18px; + height: 18px; + } + + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__latestText { + flex: 1 1 auto; + min-width: 0; + } + + &__latestTitle, + &__latestMeta { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__latestTitle { + font-size: @text-base; + font-weight: 500; + color: @color-text; + } + + &__latestMeta { + margin-top: @space-1; + font-size: @text-sm; + color: @color-muted; + } + + /* Fixed widths on the three right-hand columns, so the edges line up down the list instead of + ragging with the length of each title. */ + &__latestPrice { + flex: none; + width: 92px; + text-align: right; + font-family: @font-mono; + font-size: @text-base; + font-weight: 700; + color: @color-text; + } + + &__latestSqm { + display: flex; + flex: none; + justify-content: flex-end; + width: 108px; + } + + &__latestAge { + flex: none; + width: 74px; + text-align: right; + font-size: @text-sm; + color: @color-muted; + } + + &__latestEmpty { + padding: @space-4 0; + color: @color-muted; + font-size: @text-base; + } + + /* Which job is working and which is not. Two jobs and no way to tell them apart was the whole + content of the tile this replaces. */ + &__jobs { + display: flex; + flex-direction: column; margin-bottom: @space-3; } - &__trend-value { - margin-right: @space-2; + &__jobRow { + display: flex; + align-items: center; + gap: @space-3; + width: 100%; + height: @dash-job-row; + padding: 0; + border: none; + border-top: 1px solid @color-border; + background: none; + font-family: @font-ui; + text-align: left; + cursor: pointer; + transition: background @transition-fast; + + &:hover { + background: @color-hover; + } + } + + &__jobText { + flex: 1 1 auto; + min-width: 0; + } + + &__jobName { + display: flex; + align-items: center; + gap: @space-2; + font-size: @text-base; + font-weight: 500; + color: @color-text; + } + + &__jobWarning { + flex: none; + color: @color-warning; + } + + &__jobMeta { + display: block; + margin-top: @space-1; + font-size: @text-sm; + color: @color-muted; + } + + &__jobSpark { + flex: none; + width: @dash-spark-w; + height: @dash-spark-h; + color: @color-muted; + } + + &__jobCount { + flex: none; + width: 58px; + text-align: right; font-family: @font-mono; - font-size: @text-xl; + font-size: @text-base; font-weight: 700; color: @color-text; + + &--zero { + color: @color-faint; + } } - &__trend-change { + /* Fourteen daily bars. A smoothed line invented values between whole-day counts, and its header + claimed a seven-day window the curve did not show. */ + &__trend { + height: @dash-trend-h; + } + + &__trendLegend { + display: flex; + align-items: center; + gap: @space-4; + margin-top: @space-3; font-size: @text-sm; + color: @color-muted; + } - &--up { - color: @color-success; + &__trendKey { + display: inline-flex; + align-items: center; + gap: @space-2; + } + + &__trendSwatch { + width: 10px; + height: 10px; + border-radius: @radius-chip; + background: @color-muted; + + &--current { + background: @color-accent; } + } - &--down { - color: @color-muted; + /* Jobs that are not doing what their owner thinks they are. Only rendered when there are any: + a permanent panel reading "all good" is a panel people stop reading. */ + &__attention { + &-list { + margin: 0; + padding: 0; + list-style: none; + } + + &-item { + display: flex; + align-items: center; + gap: @space-2; + padding: @space-2 0; + border-bottom: 1px solid @color-border; + + &:last-child { + border-bottom: none; + } + } + + &-icon { + flex: none; + color: @color-warning; + } + + &-text { + flex: 1 1 auto; + color: @color-text; + font-size: @text-sm; } } @@ -106,6 +426,32 @@ &__empty-body { line-height: 1.6; } + + /* One column. The tiles come before the content because on a single column the price is the + first question, and the rail's cards follow the main column rather than splitting it. */ + @media (max-width: @dash-stack) { + &__grid { + grid-template-columns: minmax(0, 1fr); + } + + &__kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: @dash-narrow) { + &__kpis { + grid-template-columns: minmax(0, 1fr); + } + + &__latestSqm { + display: none; + } + + &__latestAge { + width: 62px; + } + } } /* Interval, last run and next run, as one line instead of three cards. */ @@ -122,35 +468,3 @@ color: @color-faint; } } - -/* Jobs that are not doing what their owner thinks they are. Only rendered when there are any. */ -.dashboard__attention { - &-list { - margin: 0; - padding: 0; - list-style: none; - } - - &-item { - display: flex; - align-items: center; - gap: @space-2; - padding: @space-2 0; - border-bottom: 1px solid @color-border; - - &:last-child { - border-bottom: none; - } - } - - &-icon { - flex: none; - color: @color-warning; - } - - &-text { - flex: 1 1 auto; - color: @color-text; - font-size: @text-sm; - } -} diff --git a/ui/src/views/dashboard/components/JobsPanel.jsx b/ui/src/views/dashboard/components/JobsPanel.jsx new file mode 100644 index 00000000..abdc6e08 --- /dev/null +++ b/ui/src/views/dashboard/components/JobsPanel.jsx @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { IconAlertTriangle } from '@douyinfe/semi-icons'; + +import { relativeTime } from '../dashboardTime.js'; + +/** Seven bars of six, with three between them, is exactly the sixty the stylesheet reserves. */ +const SPARK_BAR_W = 6; +const SPARK_GAP = 3; +const SPARK_H = 24; +/** The tallest a bar may draw, leaving the baseline visible under a full day. */ +const SPARK_MAX_BAR = 22; +/** A day with nothing still draws, so seven empty days read as a flat line and not as no chart. */ +const SPARK_MIN_BAR = 2; + +/** Seven days of nothing, for a job the activity payload does not mention. */ +const NO_ACTIVITY = { perDay: [0, 0, 0, 0, 0, 0, 0], total: 0 }; + +/** + * One job's last seven days, as seven bars. + * + * Scaled against that job's own busiest day rather than against the other jobs: the question a + * reader has here is "is this one still working", not "which of these two found more", and a + * shared scale would flatten a quiet search into a blank row next to a busy one. + * + * @param {Object} props + * @param {number[]} props.perDay Oldest first, seven entries. + * @returns {React.ReactElement} + */ +function JobSpark({ perDay }) { + const max = Math.max(1, ...perDay); + return ( + + ); +} + +JobSpark.displayName = 'JobSpark'; + +/** + * Every search the user has, with what each of them actually did this week. + * + * This replaces a tile that read "Jobs 2". The count was the whole of what the dashboard said + * about the searches, which is the one thing a reader already knows: what they cannot see is + * which of those searches is still finding anything, and which one has been quietly returning + * nothing since its portal URL stopped working. + * + * @param {Object} props + * @param {Array} props.jobs + * @param {Record} props.jobActivity Keyed by job id. + * @param {Array<{id: string}>} props.attention Jobs that want looking at, from + * `findJobsNeedingAttention`. + * @param {(key: string, params?: Object) => string} props.t + * @param {() => void} props.onManage Opens the jobs page. + * @param {(id: string) => void} props.onOpenJob Opens one job. + * @returns {React.ReactElement} + */ +export default function JobsPanel({ jobs = [], jobActivity = {}, attention = [], t, onManage, onOpenJob }) { + const rows = Array.isArray(jobs) ? jobs : []; + const flagged = new Set((Array.isArray(attention) ? attention : []).map((entry) => entry.id)); + + return ( +
+
+

{t('dashboard.sectionJobs')}

+ + {rows.length} +
+
+ {rows.map((job) => { + const activity = jobActivity?.[job.id] ?? NO_ACTIVITY; + const time = relativeTime(job.lastRunAt, t) ?? '---'; + return ( + + ); + })} +
+ +
+ ); +} + +JobsPanel.displayName = 'JobsPanel'; diff --git a/ui/src/views/dashboard/components/LatestListings.jsx b/ui/src/views/dashboard/components/LatestListings.jsx new file mode 100644 index 00000000..a312ae73 --- /dev/null +++ b/ui/src/views/dashboard/components/LatestListings.jsx @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { formatEuroPrice } from '../../../services/price/priceService.js'; +import { formatPricePerSqm } from '../../../services/listings/marketBenchmark.js'; +import { formatDecimal } from '../../../services/number/numberService.js'; +import { relativeTime } from '../dashboardTime.js'; + +/** The thumbnail a listing without a usable photo falls back to. Drawn, not imported: one icon + * does not earn an asset, and an inline path follows the theme through currentColor. */ +const THUMB_PLACEHOLDER = ( + +); + +/** + * The address, living space and room count of one listing, as one line. + * + * Whatever is missing is left out rather than filled with a dash: three separators around two + * empty slots say less than the one fact that is actually known. + * + * @param {Object} row + * @param {string} locale + * @param {(key: string, params?: Object) => string} t + * @returns {string} + */ +function metaLine(row, locale, t) { + return [ + row.address, + row.size == null ? null : `${formatDecimal(row.size, locale)} m²`, + row.rooms == null ? null : t('listing.detail.fieldRoomsValue', { count: formatDecimal(row.rooms, locale) }), + ] + .filter(Boolean) + .join(' · '); +} + +/** + * The newest listings across every search the user can see. + * + * This is what the dashboard is for and it used to be one navigation away: the page led with + * standing totals and left the bottom half of the screen empty, so "what came in" was a question + * you had to leave the dashboard to answer. + * + * Every row is a real `