Calculating the distance (#255)

* migra for distance

* adding distance calculator

* adding ability to store home address

* improve distance calculation

* calculating distance

* show distance in grid view

* upgrading dependencies

* moving to react 19

* ability to clone a job

* fixing tests

* polishing
This commit is contained in:
Christian Kellner
2026-01-22 16:09:36 +01:00
committed by GitHub
parent 51b4e51f3f
commit 4dd0370ec1
47 changed files with 1135 additions and 615 deletions

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
import {
IconTerminal,
IconStar,

View File

@@ -7,11 +7,11 @@ import React from 'react';
import { useActions, useSelector } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui';
import { InputNumber } from '@douyinfe/semi-ui';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui';
import { Banner, Toast } from '@douyinfe/semi-ui-19';
import {
downloadBackup as downloadBackupZip,
precheckRestore as clientPrecheckRestore,

View File

@@ -12,8 +12,8 @@ import ProviderMutator from './components/provider/ProviderMutator';
import Headline from '../../../components/headline/Headline';
import { useActions, useSelector } from '../../../services/state/store';
import { xhrPost } from '../../../services/xhr';
import { useNavigate, useParams } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui-19';
import './JobMutation.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import {
@@ -30,14 +30,20 @@ export default function JobMutator() {
const jobs = useSelector((state) => state.jobsData.jobs);
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
const params = useParams();
const location = useLocation();
const cloneFromId = location.state?.cloneFrom;
const jobToClone = cloneFromId ? jobs.find((job) => job.id === cloneFromId) : null;
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
const defaultBlacklist = jobToBeEdit?.blacklist || [];
const defaultName = jobToBeEdit?.name || null;
const defaultProviderData = jobToBeEdit?.provider || [];
const defaultNotificationAdapter = jobToBeEdit?.notificationAdapter || [];
const defaultEnabled = jobToBeEdit?.enabled ?? true;
const sourceJob = jobToBeEdit || jobToClone;
const defaultBlacklist = sourceJob?.blacklist || [];
const defaultName = jobToClone ? `Copy of - ${sourceJob?.name}` : sourceJob?.name || null;
const defaultProviderData = sourceJob?.provider || [];
const defaultNotificationAdapter = sourceJob?.notificationAdapter || [];
const defaultEnabled = sourceJob?.enabled ?? true;
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
const [providerToEdit, setProviderToEdit] = useState(null);
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
@@ -47,7 +53,7 @@ export default function JobMutator() {
const [name, setName] = useState(defaultName);
const [blacklist, setBlacklist] = useState(defaultBlacklist);
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []);
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
const [enabled, setEnabled] = useState(defaultEnabled);
const navigate = useNavigate();
const actions = useActions();

View File

@@ -9,7 +9,7 @@ import { transform } from '../../../../../services/transformer/notificationAdapt
import { xhrPost } from '../../../../../services/xhr';
import Help from './NotificationHelpDisplay';
import { useSelector } from '../../../../../services/state/store';
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui';
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui-19';
import './NotificationAdapterMutator.less';
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
export default function Help({ readme }) {
return (

View File

@@ -5,7 +5,7 @@
import React, { useState, useEffect } from 'react';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
import { transform } from '../../../../../services/transformer/providerTransformer';
import { useSelector } from '../../../../../services/state/store';
import { IconLikeHeart } from '@douyinfe/semi-icons';

View File

@@ -7,7 +7,7 @@ import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui-19';
import { IconFilter } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg';
import RangeSlider from 'react-range-slider-input';
@@ -281,12 +281,14 @@ export default function MapView() {
</div>
</div>
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
</div>

View File

@@ -6,7 +6,7 @@
import React, { useState } from 'react';
import { IconHorn } from '@douyinfe/semi-icons';
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19';
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
import Headline from '../../../components/headline/Headline.jsx';

View File

@@ -10,7 +10,7 @@ import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';
import { useActions, useSelector } from '../../services/state/store';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui-19';
import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons';

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { Modal } from '@douyinfe/semi-ui-19';
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
return (
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>

View File

@@ -5,11 +5,11 @@
import React from 'react';
import { Toast } from '@douyinfe/semi-ui';
import { Toast } from '@douyinfe/semi-ui-19';
import UserTable from '../../components/table/UserTable';
import { useActions, useSelector } from '../../services/state/store';
import { IconPlus } from '@douyinfe/semi-icons';
import { Button } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui-19';
import UserRemovalModal from './UserRemovalModal';
import { xhrDelete } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';

View File

@@ -8,7 +8,7 @@ import React from 'react';
import { xhrGet, xhrPost } from '../../../services/xhr';
import { useNavigate, useParams } from 'react-router-dom';
import { useActions } from '../../../services/state/store';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19';
import './UserMutator.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import { IconPlusCircle } from '@douyinfe/semi-icons';

View File

@@ -0,0 +1,119 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useState, useMemo } from 'react';
import { Divider, Button, AutoComplete, Toast, Typography, Banner } from '@douyinfe/semi-ui-19';
import { IconSave, IconHome } from '@douyinfe/semi-icons';
import { xhrGet, xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import debounce from 'lodash/debounce';
const { Title } = Typography;
const UserSettings = () => {
const [address, setAddress] = useState('');
const [coords, setCoords] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dataSource, setDataSource] = useState([]);
useEffect(() => {
fetchUserSettings();
}, []);
const fetchUserSettings = async () => {
try {
const response = await xhrGet('/api/user/settings');
if (response.status === 200) {
const homeAddress = response.json.home_address;
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}
} catch {
Toast.error('Failed to fetch user settings');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const response = await xhrPost('/api/user/settings', { home_address: address });
if (response.status === 200) {
setCoords(response.json.coords);
Toast.success('Settings saved successfully');
} else {
Toast.error(response.json.error || 'Failed to save settings');
}
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
} finally {
setSaving(false);
}
};
const debouncedSearch = useMemo(
() =>
debounce((value) => {
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
.then((response) => {
if (response.status === 200) {
setDataSource(response.json);
}
})
.catch(() => {
// Silently fail for autocomplete
});
}, 300),
[],
);
const searchAddress = (value) => {
if (!value) {
setDataSource([]);
return;
}
debouncedSearch(value);
};
if (loading) {
return null;
}
return (
<div className="user-settings">
<Title heading={2}>User Specific Settings</Title>
<Divider />
<SegmentPart
name="Distance claculation"
Icon={IconHome}
helpText="The address you enter is used to calculate the distance between your chosen location and each listing. The distance is computed using an approximate mathematical method and is intended to give you a rough indication of commute time. If you update your address, we will recalculate the distance for all active listings."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '600px' }}>
<AutoComplete
data={dataSource}
value={address}
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} />
)}
</div>
</SegmentPart>
<Divider />
<div style={{ marginTop: '20px' }}>
<Button icon={<IconSave />} theme="solid" type="primary" onClick={handleSave} loading={saving}>
Save Settings
</Button>
</div>
</div>
);
};
export default UserSettings;