Skip to content

Commit 45d20bf

Browse files
committed
Rework recurrence to work with calendar months/years
1 parent 6d3b936 commit 45d20bf

File tree

8 files changed

+151
-78
lines changed

8 files changed

+151
-78
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Todolator
22

3-
Todolator is a simple desktop task reminder designed to ensure it is hard to ignore the reminder (unlike other apps using push or in-browser notifications).
3+
Todolator is a simple desktop task reminder designed to ensure it is hard(er) to ignore the reminder (unlike other apps using push or in-browser notifications).
44

55
## Why
66

src-tauri/src/datetime.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
2+
3+
pub fn add_months(dt: DateTime<Utc>, months: u32) -> DateTime<Utc> {
4+
let mut year = dt.year();
5+
let mut month = dt.month() + months;
6+
7+
while month > 12 {
8+
month -= 12;
9+
year += 1;
10+
}
11+
12+
let day = std::cmp::min(dt.day(), days_in_month(year, month));
13+
Utc.with_ymd_and_hms(year, month, day, dt.hour(), dt.minute(), dt.second())
14+
.unwrap()
15+
}
16+
17+
pub fn days_in_month(year: i32, month: u32) -> u32 {
18+
let next_month = if month == 12 { 1 } else { month + 1 };
19+
let next_year = if month == 12 { year + 1 } else { year };
20+
let first_day = Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0).unwrap();
21+
let next_first_day = Utc
22+
.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
23+
.unwrap();
24+
(next_first_day - first_day).num_days() as u32
25+
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod commands;
22
mod config;
3+
mod datetime;
34
mod tasks;
45

56
use std::{

src-tauri/src/tasks.rs

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,25 @@ use std::fs;
88
use uuid::Uuid;
99

1010
use crate::config;
11+
use crate::datetime::add_months;
12+
13+
#[derive(Serialize, Deserialize, Clone, Debug)]
14+
pub enum RecurrenceInterval {
15+
Minutes(i64),
16+
Hourly(i64),
17+
Daily(i64),
18+
Weekly(i64),
19+
Monthly(i64),
20+
Yearly(i64),
21+
}
1122

1223
#[derive(Serialize, Deserialize, Clone, Debug)]
1324
#[serde(untagged)]
1425
pub enum Recurrence {
1526
None,
1627
Recurring {
1728
last_recurrence: Option<DateTime<Utc>>,
18-
minutes: i64,
29+
interval: RecurrenceInterval,
1930
exceptions: Option<Vec<DateTime<Utc>>>,
2031
},
2132
}
@@ -149,11 +160,11 @@ impl TaskReminder {
149160
for i in 0..instances_required {
150161
let recurrence_info = if let Some(Recurrence::Recurring {
151162
last_recurrence,
152-
minutes,
163+
interval,
153164
exceptions,
154165
}) = &d.recurrence
155166
{
156-
Some((last_recurrence, minutes, exceptions))
167+
Some((last_recurrence, interval, exceptions))
157168
} else {
158169
None
159170
};
@@ -164,12 +175,51 @@ impl TaskReminder {
164175
}
165176

166177
let timestamp = match recurrence_info {
167-
Some((last_recurrence, minutes, _exceptions)) => match *last_recurrence {
168-
Some(last) => last + Duration::minutes((i + 1) * minutes),
169-
170-
None => d.start + Duration::minutes(i * minutes),
178+
Some((last_recurrence, interval, _)) => match interval {
179+
RecurrenceInterval::Minutes(number) => {
180+
if let Some(last) = last_recurrence {
181+
*last + Duration::minutes((i + 1) * number)
182+
} else {
183+
d.start + Duration::minutes(i * number)
184+
}
185+
}
186+
RecurrenceInterval::Hourly(number) => {
187+
if let Some(last) = last_recurrence {
188+
*last + Duration::hours((i + 1) * number)
189+
} else {
190+
d.start + Duration::hours(i * number)
191+
}
192+
}
193+
RecurrenceInterval::Daily(number) => {
194+
if let Some(last) = last_recurrence {
195+
*last + Duration::days((i + 1) * number)
196+
} else {
197+
d.start + Duration::days(i * number)
198+
}
199+
}
200+
RecurrenceInterval::Weekly(number) => {
201+
if let Some(last) = last_recurrence {
202+
*last + Duration::weeks((i + 1) * number)
203+
} else {
204+
d.start + Duration::weeks(i * number)
205+
}
206+
}
207+
RecurrenceInterval::Monthly(number) => {
208+
let mut t = last_recurrence.unwrap_or(d.start);
209+
for _ in 0..=(i as u32) {
210+
t = add_months(t, *number as u32);
211+
}
212+
t
213+
}
214+
RecurrenceInterval::Yearly(number) => {
215+
let mut t = last_recurrence.unwrap_or(d.start);
216+
for _ in 0..=(i as u32) {
217+
t = add_months(t, 12 * (*number as u32));
218+
}
219+
t
220+
}
171221
},
172-
_ => d.start,
222+
None => d.start,
173223
};
174224

175225
let exceptions = if let Some(recurrence) = recurrence_info {
@@ -251,7 +301,7 @@ impl TaskReminder {
251301
if let Some(definition) = self.get_task_definition_mut(task.definition_id) {
252302
if let Some(Recurrence::Recurring {
253303
last_recurrence,
254-
minutes: _,
304+
interval: _,
255305
exceptions: _,
256306
}) = &mut definition.recurrence
257307
{
@@ -316,7 +366,7 @@ mod tests {
316366
start: Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(),
317367
recurrence: Some(Recurrence::Recurring {
318368
last_recurrence: None,
319-
minutes: 60,
369+
interval: RecurrenceInterval::Minutes(60),
320370
exceptions: None,
321371
}),
322372
}
@@ -436,7 +486,7 @@ mod tests {
436486
start,
437487
recurrence: Some(Recurrence::Recurring {
438488
last_recurrence: None,
439-
minutes: 60,
489+
interval: RecurrenceInterval::Minutes(60),
440490
exceptions: Some(vec![exception_time]),
441491
}),
442492
}],
@@ -462,7 +512,7 @@ mod tests {
462512
start: Utc.with_ymd_and_hms(2025, 1, 1, 8, 0, 0).unwrap(),
463513
recurrence: Some(Recurrence::Recurring {
464514
last_recurrence: None,
465-
minutes: 5, // small interval to fill multiple pages quickly
515+
interval: RecurrenceInterval::Minutes(60),
466516
exceptions: None,
467517
}),
468518
};

src/components/TaskForm.vue

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
tabindex="6"
131131
>
132132
<option
133-
v-for="[unit] of Object.entries(timeUnitToMinutesMap)"
133+
v-for="[unit] of Object.entries(intervalMap)"
134134
:key="unit"
135135
:value="unit"
136136
class="hover:bg-secondary hover:text-primary hover:font-bold hover:cursor-pointer"
@@ -168,12 +168,8 @@
168168
<br />
169169
<span
170170
>repeats every
171-
<span class="text-warning font-bold">{{ recurrence![Recurrence.AMOUNT] }}</span>
172-
{{
173-
(recurrence![Recurrence.AMOUNT] as number) > 1
174-
? pluralizeUnit(recurrence![Recurrence.UNIT] as string)
175-
: recurrence![Recurrence.UNIT]
176-
}}</span
171+
<span class="text-warning font-bold">{{ displayUnitNumber }}</span>
172+
{{ displayUnitNumber > 1 ? pluralizeUnit(displayUnitName) : displayUnitName }}</span
177173
>
178174
</div>
179175
</div>
@@ -203,20 +199,27 @@
203199
import { computed, nextTick, ref, watch } from 'vue'
204200
205201
import PHotkeys from './PHotkeys.vue'
206-
import { CreatedTaskDefinition, TaskDefinition } from '../composables/useTasks'
202+
import { CreatedTaskDefinition, Interval, TaskDefinition } from '../composables/useTasks'
207203
import {
208204
getDefaultRecurrence,
209205
getDefaultTaskDefinition,
210206
isExistingDefinition,
211207
isRecurringDefinition
212208
} from '../helpers/task'
213-
import {
214-
convertMinutesToHighestUnit,
215-
pluralizeUnit,
216-
timeUnitToMinutesMap
217-
} from '../helpers/datetime'
209+
import { pluralizeUnit, timeUnitToMinutesMap } from '../helpers/datetime'
218210
import PIcon from './PIcon.vue'
219211
212+
type IntervalKey = 'Minutes' | 'Hourly' | 'Daily' | 'Weekly' | 'Monthly' | 'Yearly'
213+
214+
const intervalMap = {
215+
minute: 'Minutes',
216+
hour: 'Hourly',
217+
day: 'Daily',
218+
week: 'Weekly',
219+
month: 'Monthly',
220+
year: 'Yearly'
221+
} as const satisfies Record<string, IntervalKey>
222+
220223
const props = withDefaults(
221224
defineProps<{
222225
// Provided if updating an existing definition
@@ -235,6 +238,30 @@ const props = withDefaults(
235238
236239
const localTask = ref(props.currentTask)
237240
241+
const displayUnitName = computed(() => {
242+
if (localTask.value.recurrence) {
243+
const [unit] = Object.entries(localTask.value.recurrence.interval)[0]
244+
245+
// [["minute", "Minutes"], ...]
246+
const unitName = Object.entries(intervalMap).find((i) => i[1] === unit)
247+
248+
return unitName ? unitName[0] : ''
249+
}
250+
251+
return ''
252+
})
253+
254+
const displayUnitNumber = computed(() => {
255+
if (localTask.value.recurrence) {
256+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
257+
const [_unit, amount] = Object.entries(localTask.value.recurrence.interval)[0]
258+
259+
return amount
260+
}
261+
262+
return 1
263+
})
264+
238265
enum Mode {
239266
CREATE,
240267
UPDATE
@@ -244,22 +271,17 @@ const highlightClass = computed(() => (mode.value === Mode.CREATE ? 'secondary'
244271
245272
const isRecurring = computed(() => isRecurringDefinition(localTask.value))
246273
247-
enum Recurrence {
248-
AMOUNT,
249-
UNIT
250-
}
251-
252-
// Used for determining recurrence unit and amount when editing a task (read-only)
253-
const recurrence = computed(() => convertMinutesToHighestUnit(localTask.value.recurrence?.minutes))
254-
255274
// Used for specifying recurrence when creating a new task
256275
const unitAmount = ref<number>(1)
257-
const unitName = ref<keyof typeof timeUnitToMinutesMap>('minute')
276+
const unitName = ref<keyof typeof timeUnitToMinutesMap>('day')
258277
259278
// If the selected time unit or amount changes, recalculate the resulting amount of minutes
260279
watch([unitName, unitAmount], () => {
261280
if (localTask.value.recurrence) {
262-
localTask.value.recurrence.minutes = unitAmount.value * timeUnitToMinutesMap[unitName.value]
281+
const key = intervalMap[unitName.value]
282+
localTask.value.recurrence.interval = {
283+
[key]: unitAmount.value
284+
} as Interval
263285
}
264286
})
265287
@@ -284,7 +306,7 @@ watch(
284306
// Reset the input values
285307
localTask.value = getDefaultTaskDefinition()
286308
unitAmount.value = 1
287-
unitName.value = 'minute'
309+
unitName.value = 'day'
288310
}
289311
}
290312
)

src/composables/useTasks.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import { Ref, ref } from 'vue'
44
export type UuidString = Branded<string, 'Uuidstring'>
55
export type DateTimeString = Branded<string, 'DateTimeString'>
66

7+
export type Interval =
8+
| { Minutes: number }
9+
| { Hourly: number }
10+
| { Daily: number }
11+
| { Weekly: number }
12+
| { Monthly: number }
13+
| { Yearly: number }
14+
715
export interface CreatedTaskDefinition {
816
name: string
917
desc: string | null
1018
start: DateTimeString
1119
recurrence: {
1220
last_recurrence: DateTimeString | null
13-
minutes: number
21+
interval: Interval
1422
exceptions: Array<DateTimeString> | null
1523
} | null
1624
}
@@ -45,7 +53,7 @@ export const useTasks = () => {
4553
if (
4654
!taskDefinition.name ||
4755
!taskDefinition.start ||
48-
(taskDefinition.recurrence && !taskDefinition.recurrence.minutes)
56+
(taskDefinition.recurrence && !taskDefinition.recurrence.interval)
4957
) {
5058
taskCreationError.value = 'Missing required attributes!'
5159
throw new Error('Missing required attributes')
@@ -57,7 +65,7 @@ export const useTasks = () => {
5765
start: new Date(taskDefinition.start).toISOString() as DateTimeString,
5866
recurrence: taskDefinition.recurrence
5967
? {
60-
minutes: taskDefinition.recurrence.minutes,
68+
interval: taskDefinition.recurrence.interval,
6169
exceptions: taskDefinition.recurrence.exceptions,
6270
last_recurrence: taskDefinition.recurrence.last_recurrence
6371
}
@@ -84,7 +92,7 @@ export const useTasks = () => {
8492
start: new Date(taskDefinition.start).toISOString() as DateTimeString,
8593
recurrence: taskDefinition.recurrence
8694
? {
87-
minutes: taskDefinition.recurrence.minutes,
95+
interval: taskDefinition.recurrence.interval,
8896
exceptions: taskDefinition.recurrence.exceptions,
8997
last_recurrence: taskDefinition.recurrence.last_recurrence
9098
}

src/helpers/datetime.ts

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ const week = day * 7
1414
const month = week * 4
1515
const year = month * 12
1616

17-
export const enum TimeUnitEnum {
18-
NAME,
19-
MINUTES
20-
}
2117
export const timeUnitToMinutesMap = {
2218
minute,
2319
hour,
@@ -27,35 +23,4 @@ export const timeUnitToMinutesMap = {
2723
year
2824
} as const
2925

30-
export const timeUnits = [year, month, week, day, hour, minute] as const
31-
export const convertMinutesToHighestUnit = (minutes: number | undefined) => {
32-
if (!minutes) return
33-
34-
let foundUnit: keyof typeof timeUnitToMinutesMap | undefined
35-
let foundAmount
36-
for (const unitAmount of timeUnits) {
37-
if (minutes % unitAmount === 0) {
38-
const foundUnitObj = Object.entries(timeUnitToMinutesMap).find(
39-
({ 1: minutes }) => minutes === unitAmount
40-
)
41-
42-
if (foundUnitObj) {
43-
foundUnit = foundUnitObj[TimeUnitEnum.NAME] as keyof typeof timeUnitToMinutesMap
44-
}
45-
46-
foundAmount = minutes / unitAmount
47-
48-
break
49-
}
50-
}
51-
52-
if (foundUnit && foundAmount && foundAmount > 1) {
53-
pluralizeUnit(foundUnit)
54-
}
55-
56-
console.log('Found unit: ', foundUnit)
57-
58-
return [foundAmount, foundUnit?.toLowerCase()]
59-
}
60-
6126
export const pluralizeUnit = (unit: string) => unit + 's'

0 commit comments

Comments
 (0)