Skip to content

Commit 6244965

Browse files
FEATURE (intervals): When the time is selected on UI, adjust it to UTC before saving in API
1 parent 82e0514 commit 6244965

File tree

11 files changed

+191
-123
lines changed

11 files changed

+191
-123
lines changed

backend/internal/features/intervals/model_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ func TestInterval_ShouldTriggerBackup_Daily(t *testing.T) {
9494
assert.True(t, should)
9595
},
9696
)
97+
98+
t.Run(
99+
"Backup yesterday at 15:00: Trigger backup today at 09:00",
100+
func(t *testing.T) {
101+
now := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC)
102+
lastBackup := time.Date(2024, 1, 14, 15, 0, 0, 0, time.UTC) // Yesterday at 15:00
103+
should := interval.ShouldTriggerBackup(now, &lastBackup)
104+
assert.True(t, should)
105+
},
106+
)
97107
}
98108

99109
func TestInterval_ShouldTriggerBackup_Weekly(t *testing.T) {
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export enum BackupNotificationType {
2-
BACKUP_FAILED = "BACKUP_FAILED",
3-
BACKUP_SUCCESS = "BACKUP_SUCCESS",
2+
BACKUP_FAILED = 'BACKUP_FAILED',
3+
BACKUP_SUCCESS = 'BACKUP_SUCCESS',
44
}
Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
export enum Period {
2-
DAY = "DAY",
3-
WEEK = "WEEK",
4-
MONTH = "MONTH",
5-
THREE_MONTH = "3_MONTH",
6-
SIX_MONTH = "6_MONTH",
7-
YEAR = "YEAR",
8-
TWO_YEARS = "2_YEARS",
9-
THREE_YEARS = "3_YEARS",
10-
FOUR_YEARS = "4_YEARS",
11-
FIVE_YEARS = "5_YEARS",
12-
FOREVER = "FOREVER",
13-
}
2+
DAY = 'DAY',
3+
WEEK = 'WEEK',
4+
MONTH = 'MONTH',
5+
THREE_MONTH = '3_MONTH',
6+
SIX_MONTH = '6_MONTH',
7+
YEAR = 'YEAR',
8+
TWO_YEARS = '2_YEARS',
9+
THREE_YEARS = '3_YEARS',
10+
FOUR_YEARS = '4_YEARS',
11+
FIVE_YEARS = '5_YEARS',
12+
FOREVER = 'FOREVER',
13+
}
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export enum PostgresqlVersion {
2-
PostgresqlVersion13 = "13",
3-
PostgresqlVersion14 = "14",
4-
PostgresqlVersion15 = "15",
5-
PostgresqlVersion16 = "16",
6-
PostgresqlVersion17 = "17",
2+
PostgresqlVersion13 = '13',
3+
PostgresqlVersion14 = '14',
4+
PostgresqlVersion15 = '15',
5+
PostgresqlVersion16 = '16',
6+
PostgresqlVersion17 = '17',
77
}

frontend/src/entity/restores/model/Restore.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { RestoreStatus } from './RestoreStatus';
44
export interface Restore {
55
id: string;
66
status: RestoreStatus;
7-
7+
88
postgresql?: PostgresqlDatabase;
9-
9+
1010
failMessage?: string;
11-
11+
1212
restoreDurationMs: number;
1313
createdAt: string;
1414
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export type LocalStorage = object
1+
export type LocalStorage = object;

frontend/src/features/databases/ui/edit/EditDatabaseBaseInfoComponent.tsx

Lines changed: 91 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,13 @@ import { useEffect, useMemo, useState } from 'react';
66
import { type Database, databaseApi } from '../../../../entity/databases';
77
import { Period } from '../../../../entity/databases/model/Period';
88
import { type Interval, IntervalType } from '../../../../entity/intervals';
9-
10-
interface Props {
11-
database: Database;
12-
13-
isShowName?: boolean;
14-
15-
isShowCancelButton?: boolean;
16-
onCancel: () => void;
17-
18-
saveButtonText?: string;
19-
isSaveToApi: boolean;
20-
onSaved: (database: Database) => void;
21-
}
9+
import {
10+
getLocalDayOfMonth,
11+
getLocalWeekday,
12+
getUserTimeFormat,
13+
getUtcDayOfMonth,
14+
getUtcWeekday,
15+
} from '../../../../shared/time/utils';
2216

2317
const weekdayOptions = [
2418
{ value: 1, label: 'Mon' },
@@ -30,22 +24,23 @@ const weekdayOptions = [
3024
{ value: 7, label: 'Sun' },
3125
];
3226

33-
// Function to detect if user prefers 12-hour format based on their locale
34-
const getUserTimeFormat = () => {
35-
const locale = navigator.language || 'en-US';
36-
const testDate = new Date(2023, 0, 1, 13, 0, 0); // 1 PM
37-
const timeString = testDate.toLocaleTimeString(locale, { hour: 'numeric' });
38-
return timeString.includes('PM') || timeString.includes('AM');
39-
};
27+
interface Props {
28+
database: Database;
29+
30+
isShowName?: boolean;
31+
isShowCancelButton?: boolean;
32+
onCancel: () => void;
33+
34+
saveButtonText?: string;
35+
isSaveToApi: boolean;
36+
onSaved: (db: Database) => void;
37+
}
4038

4139
export const EditDatabaseBaseInfoComponent = ({
4240
database,
43-
4441
isShowName,
45-
4642
isShowCancelButton,
4743
onCancel,
48-
4944
saveButtonText,
5045
isSaveToApi,
5146
onSaved,
@@ -54,73 +49,83 @@ export const EditDatabaseBaseInfoComponent = ({
5449
const [isUnsaved, setIsUnsaved] = useState(false);
5550
const [isSaving, setIsSaving] = useState(false);
5651

57-
// Detect user's preferred time format (12-hour vs 24-hour)
5852
const timeFormat = useMemo(() => {
59-
const is12Hour = getUserTimeFormat();
60-
return {
61-
use12Hours: is12Hour,
62-
format: is12Hour ? 'h:mm A' : 'HH:mm',
63-
};
53+
const is12 = getUserTimeFormat();
54+
return { use12Hours: is12, format: is12 ? 'h:mm A' : 'HH:mm' };
6455
}, []);
6556

6657
const updateDatabase = (patch: Partial<Database>) => {
67-
if (!editingDatabase) return;
68-
setEditingDatabase({ ...editingDatabase, ...patch });
58+
setEditingDatabase((prev) => (prev ? { ...prev, ...patch } : prev));
59+
setIsUnsaved(true);
60+
};
61+
62+
const saveInterval = (patch: Partial<Interval>) => {
63+
setEditingDatabase((prev) => {
64+
if (!prev) return prev;
65+
66+
const updatedBackupInterval = { ...(prev.backupInterval ?? {}), ...patch };
67+
68+
if (!updatedBackupInterval.id && prev.backupInterval?.id) {
69+
updatedBackupInterval.id = prev.backupInterval.id;
70+
}
71+
72+
return { ...prev, backupInterval: updatedBackupInterval as Interval };
73+
});
74+
6975
setIsUnsaved(true);
7076
};
7177

7278
const saveDatabase = async () => {
7379
if (!editingDatabase) return;
74-
7580
if (isSaveToApi) {
7681
setIsSaving(true);
77-
7882
try {
7983
await databaseApi.updateDatabase(editingDatabase);
8084
setIsUnsaved(false);
8185
} catch (e) {
8286
alert((e as Error).message);
8387
}
84-
8588
setIsSaving(false);
8689
}
87-
8890
onSaved(editingDatabase);
8991
};
9092

91-
const saveInterval = (patch: Partial<Interval>) => {
92-
if (!editingDatabase) return;
93-
const current = editingDatabase.backupInterval ?? ({} as Interval);
94-
updateDatabase({ backupInterval: { ...current, ...patch } });
95-
};
96-
9793
useEffect(() => {
9894
setIsSaving(false);
9995
setIsUnsaved(false);
100-
10196
setEditingDatabase({ ...database });
10297
}, [database]);
10398

10499
if (!editingDatabase) return null;
105-
106100
const { backupInterval } = editingDatabase;
107101

102+
// UTC → local conversions for display
108103
const localTime: Dayjs | undefined = backupInterval?.timeOfDay
109-
? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local() /* cast to user tz */
104+
? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local()
110105
: undefined;
111106

112-
let isAllFieldsFilled = true;
113-
114-
if (!editingDatabase.name) isAllFieldsFilled = false;
115-
if (!editingDatabase.storePeriod) isAllFieldsFilled = false;
116-
117-
if (!editingDatabase.backupInterval?.interval) isAllFieldsFilled = false;
118-
if (editingDatabase.backupInterval?.interval === IntervalType.WEEKLY) {
119-
if (!editingDatabase.backupInterval?.weekday) isAllFieldsFilled = false;
120-
}
121-
if (editingDatabase.backupInterval?.interval === IntervalType.MONTHLY) {
122-
if (!editingDatabase.backupInterval.dayOfMonth) isAllFieldsFilled = false;
123-
}
107+
const displayedWeekday: number | undefined =
108+
backupInterval?.interval === IntervalType.WEEKLY &&
109+
backupInterval.weekday &&
110+
backupInterval.timeOfDay
111+
? getLocalWeekday(backupInterval.weekday, backupInterval.timeOfDay)
112+
: backupInterval?.weekday;
113+
114+
const displayedDayOfMonth: number | undefined =
115+
backupInterval?.interval === IntervalType.MONTHLY &&
116+
backupInterval.dayOfMonth &&
117+
backupInterval.timeOfDay
118+
? getLocalDayOfMonth(backupInterval.dayOfMonth, backupInterval.timeOfDay)
119+
: backupInterval?.dayOfMonth;
120+
121+
// mandatory-field check
122+
const isAllFieldsFilled =
123+
Boolean(editingDatabase.name) &&
124+
Boolean(editingDatabase.storePeriod) &&
125+
Boolean(backupInterval?.interval) &&
126+
(!backupInterval ||
127+
((backupInterval.interval !== IntervalType.WEEKLY || displayedWeekday) &&
128+
(backupInterval.interval !== IntervalType.MONTHLY || displayedDayOfMonth)));
124129

125130
return (
126131
<div>
@@ -129,9 +134,7 @@ export const EditDatabaseBaseInfoComponent = ({
129134
<div className="min-w-[150px]">Name</div>
130135
<Input
131136
value={editingDatabase.name || ''}
132-
onChange={(e) => {
133-
updateDatabase({ name: e.target.value });
134-
}}
137+
onChange={(e) => updateDatabase({ name: e.target.value })}
135138
size="small"
136139
placeholder="My favourite DB"
137140
className="max-w-[200px] grow"
@@ -143,9 +146,7 @@ export const EditDatabaseBaseInfoComponent = ({
143146
<div className="min-w-[150px]">Backup interval</div>
144147
<Select
145148
value={backupInterval?.interval}
146-
onChange={(v) => {
147-
saveInterval({ interval: v });
148-
}}
149+
onChange={(v) => saveInterval({ interval: v })}
149150
size="small"
150151
className="max-w-[200px] grow"
151152
options={[
@@ -154,22 +155,22 @@ export const EditDatabaseBaseInfoComponent = ({
154155
{ label: 'Weekly', value: IntervalType.WEEKLY },
155156
{ label: 'Monthly', value: IntervalType.MONTHLY },
156157
]}
157-
placeholder="Select backup interval"
158158
/>
159159
</div>
160160

161161
{backupInterval?.interval === IntervalType.WEEKLY && (
162162
<div className="mb-1 flex w-full items-center">
163163
<div className="min-w-[150px]">Backup weekday</div>
164164
<Select
165-
value={backupInterval.weekday}
166-
onChange={(v) => {
167-
saveInterval({ weekday: v });
165+
value={displayedWeekday}
166+
onChange={(localWeekday) => {
167+
if (!localWeekday) return;
168+
const ref = localTime ?? dayjs();
169+
saveInterval({ weekday: getUtcWeekday(localWeekday, ref) });
168170
}}
169171
size="small"
170172
className="max-w-[200px] grow"
171173
options={weekdayOptions}
172-
placeholder="Select backup weekday"
173174
/>
174175
</div>
175176
)}
@@ -180,13 +181,14 @@ export const EditDatabaseBaseInfoComponent = ({
180181
<InputNumber
181182
min={1}
182183
max={31}
183-
value={backupInterval.dayOfMonth}
184-
onChange={(v) => {
185-
saveInterval({ dayOfMonth: v ?? 1 });
184+
value={displayedDayOfMonth}
185+
onChange={(localDom) => {
186+
if (!localDom) return;
187+
const ref = localTime ?? dayjs();
188+
saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) });
186189
}}
187190
size="small"
188191
className="max-w-[200px] grow"
189-
placeholder="Select backup day of month"
190192
/>
191193
</div>
192194
)}
@@ -198,15 +200,22 @@ export const EditDatabaseBaseInfoComponent = ({
198200
value={localTime}
199201
format={timeFormat.format}
200202
use12Hours={timeFormat.use12Hours}
201-
onChange={(t) => {
202-
if (!t) return;
203-
// convert local picker value → UTC "HH:mm"
204-
const utcString = t.utc().format('HH:mm');
205-
saveInterval({ timeOfDay: utcString });
206-
}}
207203
allowClear={false}
208204
size="small"
209205
className="max-w-[200px] grow"
206+
onChange={(t) => {
207+
if (!t) return;
208+
const patch: Partial<Interval> = { timeOfDay: t.utc().format('HH:mm') };
209+
210+
if (backupInterval?.interval === IntervalType.WEEKLY && displayedWeekday) {
211+
patch.weekday = getUtcWeekday(displayedWeekday, t);
212+
}
213+
if (backupInterval?.interval === IntervalType.MONTHLY && displayedDayOfMonth) {
214+
patch.dayOfMonth = getUtcDayOfMonth(displayedDayOfMonth, t);
215+
}
216+
217+
saveInterval(patch);
218+
}}
210219
/>
211220
</div>
212221
)}
@@ -215,9 +224,7 @@ export const EditDatabaseBaseInfoComponent = ({
215224
<div className="min-w-[150px]">Store period</div>
216225
<Select
217226
value={editingDatabase.storePeriod}
218-
onChange={(v) => {
219-
updateDatabase({ storePeriod: v });
220-
}}
227+
onChange={(v) => updateDatabase({ storePeriod: v })}
221228
size="small"
222229
className="max-w-[200px] grow"
223230
options={[
@@ -236,23 +243,22 @@ export const EditDatabaseBaseInfoComponent = ({
236243
/>
237244
<Tooltip
238245
className="cursor-pointer"
239-
title="How long to keep the backups? Make sure that you have enough space on the storage you are using (local, S3, Goole Drive, etc.)."
246+
title="How long to keep the backups? Make sure you have enough storage space."
240247
>
241248
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
242249
</Tooltip>
243250
</div>
244251

245252
<div className="mt-5 flex">
246253
{isShowCancelButton && (
247-
<Button className="mr-1" danger ghost onClick={() => onCancel()}>
254+
<Button danger ghost className="mr-1" onClick={onCancel}>
248255
Cancel
249256
</Button>
250257
)}
251-
252258
<Button
253-
className={`${isShowCancelButton ? 'ml-1' : 'ml-auto'} mr-5`}
254259
type="primary"
255-
onClick={() => saveDatabase()}
260+
className={`${isShowCancelButton ? 'ml-1' : 'ml-auto'} mr-5`}
261+
onClick={saveDatabase}
256262
loading={isSaving}
257263
disabled={!isUnsaved || !isAllFieldsFilled}
258264
>

0 commit comments

Comments
 (0)