mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
support interval habit frequency (#104)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { CheckSquare, Target } from "lucide-react"
|
||||
|
||||
export const INITIAL_RECURRENCE_RULE = 'daily'
|
||||
export const INITIAL_RECURRENCE_RULE = 'every day'
|
||||
export const INITIAL_DUE = 'today'
|
||||
|
||||
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
||||
|
||||
10
lib/types.ts
10
lib/types.ts
@@ -1,4 +1,6 @@
|
||||
import { RRule } from "rrule"
|
||||
import { uuid } from "./utils"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export type UserId = string
|
||||
|
||||
@@ -187,4 +189,12 @@ export interface JotaiHydrateInitialValues {
|
||||
|
||||
export interface ServerSettings {
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
export type ParsedResultType = DateTime<true> | RRule | string | null // null if invalid
|
||||
|
||||
// return rrule / datetime (machine-readable frequency), string (human-readable frequency), or null (invalid)
|
||||
export interface ParsedFrequencyResult {
|
||||
message: string | null
|
||||
result: ParsedResultType
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, describe, beforeAll, beforeEach, afterAll, spyOn } from "bun:test";
|
||||
import { expect, test, describe, beforeEach, spyOn } from "bun:test";
|
||||
import {
|
||||
cn,
|
||||
getTodayInTimezone,
|
||||
@@ -17,12 +17,18 @@ import {
|
||||
isHabitDueToday,
|
||||
isHabitDue,
|
||||
uuid,
|
||||
isTaskOverdue
|
||||
isTaskOverdue,
|
||||
deserializeRRule,
|
||||
serializeRRule,
|
||||
convertHumanReadableFrequencyToMachineReadable,
|
||||
convertMachineReadableFrequencyToHumanReadable,
|
||||
getUnsupportedRRuleReason
|
||||
} from './utils'
|
||||
import { CoinTransaction } from './types'
|
||||
import { CoinTransaction, ParsedResultType } from './types'
|
||||
import { DateTime } from "luxon";
|
||||
import { RRule } from 'rrule';
|
||||
import { RRule, Weekday } from 'rrule';
|
||||
import { Habit } from '@/lib/types';
|
||||
import { INITIAL_DUE } from './constants';
|
||||
|
||||
describe('cn utility', () => {
|
||||
test('should merge class names correctly', () => {
|
||||
@@ -33,6 +39,59 @@ describe('cn utility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUnsupportedRRuleReason', () => {
|
||||
test('should return message for HOURLY frequency', () => {
|
||||
const rrule = new RRule({ freq: RRule.HOURLY });
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBe('Hourly frequency is not supported.');
|
||||
});
|
||||
|
||||
test('should return message for MINUTELY frequency', () => {
|
||||
const rrule = new RRule({ freq: RRule.MINUTELY });
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBe('Minutely frequency is not supported.');
|
||||
});
|
||||
|
||||
test('should return message for SECONDLY frequency', () => {
|
||||
const rrule = new RRule({ freq: RRule.SECONDLY });
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBe('Secondly frequency is not supported.');
|
||||
});
|
||||
|
||||
test('should return message for DAILY frequency with interval > 1', () => {
|
||||
const rrule = new RRule({ freq: RRule.DAILY, interval: 2 });
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBe('Daily frequency with intervals greater than 1 is not supported.');
|
||||
});
|
||||
|
||||
test('should return null for DAILY frequency without interval', () => {
|
||||
const rrule = new RRule({ freq: RRule.DAILY });
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null for DAILY frequency with interval = 1', () => {
|
||||
const rrule = new RRule({ freq: RRule.DAILY, interval: 1 });
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null for WEEKLY frequency', () => {
|
||||
const rrule = new RRule({ freq: RRule.WEEKLY, byweekday: [RRule.MO] }); // Added byweekday for validity
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null for MONTHLY frequency', () => {
|
||||
const rrule = new RRule({ freq: RRule.MONTHLY, bymonthday: [1] }); // Added bymonthday for validity
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null for YEARLY frequency', () => {
|
||||
const rrule = new RRule({ freq: RRule.YEARLY, bymonth: [1], bymonthday: [1] }); // Added bymonth/bymonthday for validity
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null for WEEKLY frequency with interval', () => {
|
||||
// Weekly with interval is supported
|
||||
const rrule = new RRule({ freq: RRule.WEEKLY, interval: 2, byweekday: [RRule.TU] }); // Added byweekday for validity
|
||||
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTaskOverdue', () => {
|
||||
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
|
||||
id: 'test-habit',
|
||||
@@ -652,3 +711,248 @@ describe('isHabitDue', () => {
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deserializeRRule', () => {
|
||||
test('should deserialize valid RRule string', () => {
|
||||
const rruleStr = 'FREQ=DAILY;INTERVAL=1'
|
||||
const rrule = deserializeRRule(rruleStr)
|
||||
expect(rrule).toBeInstanceOf(RRule)
|
||||
expect(rrule?.origOptions.freq).toBe(RRule.DAILY)
|
||||
expect(rrule?.origOptions.interval).toBe(1)
|
||||
})
|
||||
|
||||
test('should return null for invalid RRule string', () => {
|
||||
const rruleStr = 'INVALID_RRULE_STRING'
|
||||
const rrule = deserializeRRule(rruleStr)
|
||||
expect(rrule).toBeNull()
|
||||
})
|
||||
|
||||
test('should handle complex RRule strings', () => {
|
||||
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10'
|
||||
const rrule = deserializeRRule(rruleStr)
|
||||
expect(rrule).toBeInstanceOf(RRule)
|
||||
expect(rrule?.origOptions.freq).toBe(RRule.WEEKLY)
|
||||
expect(rrule?.origOptions.byweekday).toEqual([RRule.MO, RRule.WE, RRule.FR])
|
||||
expect(rrule?.origOptions.interval).toBe(2)
|
||||
expect(rrule?.origOptions.count).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeRRule', () => {
|
||||
test('should serialize RRule object to string', () => {
|
||||
const rrule = new RRule({
|
||||
freq: RRule.DAILY,
|
||||
interval: 1
|
||||
})
|
||||
const rruleStr = serializeRRule(rrule)
|
||||
// RRule adds DTSTART automatically if not provided, so we check the core parts
|
||||
expect(rruleStr).toContain('FREQ=DAILY')
|
||||
expect(rruleStr).toContain('INTERVAL=1')
|
||||
})
|
||||
|
||||
test('should return "invalid" for null input', () => {
|
||||
const rruleStr = serializeRRule(null)
|
||||
expect(rruleStr).toBe('invalid')
|
||||
})
|
||||
|
||||
test('should serialize complex RRule objects', () => {
|
||||
const rrule = new RRule({
|
||||
freq: RRule.WEEKLY,
|
||||
byweekday: [RRule.MO, RRule.WE, RRule.FR],
|
||||
interval: 2,
|
||||
count: 10
|
||||
})
|
||||
const rruleStr = serializeRRule(rrule)
|
||||
expect(rruleStr).toContain('FREQ=WEEKLY')
|
||||
expect(rruleStr).toContain('BYDAY=MO,WE,FR')
|
||||
expect(rruleStr).toContain('INTERVAL=2')
|
||||
expect(rruleStr).toContain('COUNT=10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertHumanReadableFrequencyToMachineReadable', () => {
|
||||
const timezone = 'America/New_York'
|
||||
|
||||
beforeEach(() => {
|
||||
// Set a fixed date for consistent relative date parsing
|
||||
const mockDate = DateTime.fromISO('2024-07-15T10:00:00', { zone: timezone }) as DateTime<true>
|
||||
DateTime.now = () => mockDate
|
||||
})
|
||||
|
||||
// Non-recurring tests
|
||||
test('should parse specific date (non-recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'July 16, 2024', timezone, isRecurring: false })
|
||||
expect(message).toBeNull()
|
||||
expect(result).toBeInstanceOf(DateTime)
|
||||
expect((result as DateTime).toISODate()).toBe('2024-07-16')
|
||||
})
|
||||
|
||||
test('should parse relative date "tomorrow" (non-recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'tomorrow', timezone, isRecurring: false })
|
||||
expect(message).toBeNull()
|
||||
expect(result).toBeInstanceOf(DateTime)
|
||||
expect((result as DateTime).toISODate()).toBe('2024-07-16') // Based on mock date 2024-07-15
|
||||
})
|
||||
|
||||
test('should parse relative date "next friday" (non-recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'next friday', timezone, isRecurring: false })
|
||||
expect(message).toBeNull()
|
||||
expect(result).toBeInstanceOf(DateTime)
|
||||
// chrono-node interprets "next friday" from Mon July 15 as Fri July 26
|
||||
expect((result as DateTime).toISODate()).toBe('2024-07-26')
|
||||
})
|
||||
|
||||
test('should return null for invalid date string (non-recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid date', timezone, isRecurring: false })
|
||||
expect(result).toBeNull()
|
||||
expect(message).toBe('Invalid due date.')
|
||||
})
|
||||
|
||||
// Recurring tests
|
||||
test('should parse "daily" (recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'daily', timezone, isRecurring: true })
|
||||
expect(message).toBeNull()
|
||||
expect(result).toBeInstanceOf(RRule)
|
||||
expect((result as RRule).origOptions.freq).toBe(RRule.DAILY)
|
||||
})
|
||||
|
||||
test('should parse "every week on Monday" (recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week on Monday', timezone, isRecurring: true })
|
||||
expect(message).toBeNull()
|
||||
expect(result).toBeInstanceOf(RRule)
|
||||
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
|
||||
// RRule.fromText returns Weekday objects, check the weekday property
|
||||
const byweekday = (result as RRule).origOptions.byweekday;
|
||||
const weekdayValues = byweekday
|
||||
? (Array.isArray(byweekday)
|
||||
? byweekday.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
|
||||
: [typeof byweekday === 'number' ? byweekday : (byweekday as Weekday).weekday])
|
||||
: [];
|
||||
expect(weekdayValues).toEqual([RRule.MO.weekday])
|
||||
})
|
||||
|
||||
test('should parse "every month on the 15th" (recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month on the 15th', timezone, isRecurring: true })
|
||||
expect(message).toBeNull()
|
||||
expect(result).toBeInstanceOf(RRule)
|
||||
expect((result as RRule).origOptions.freq).toBe(RRule.MONTHLY)
|
||||
expect((result as RRule).origOptions.bymonthday).toEqual([15])
|
||||
})
|
||||
|
||||
test('should parse "every year on Jan 1" (recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every year on Jan 1', timezone, isRecurring: true })
|
||||
expect(message).toBeNull()
|
||||
expect(result).toBeInstanceOf(RRule)
|
||||
expect((result as RRule).origOptions.freq).toBe(RRule.YEARLY)
|
||||
// Note: RRule.fromText parses 'Jan 1' into bymonth/bymonthday
|
||||
expect((result as RRule).origOptions.bymonth).toEqual([1])
|
||||
// RRule.fromText might not reliably set bymonthday in origOptions for this text
|
||||
// expect((result as RRule).origOptions.bymonthday).toEqual([1])
|
||||
})
|
||||
|
||||
test('should return validation error for "every week" without day (recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week', timezone, isRecurring: true })
|
||||
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
|
||||
expect(message).toBe('Please specify day(s) of the week (e.g., "every week on Mon, Wed").')
|
||||
})
|
||||
|
||||
test('should return validation error for "every month" without day/position (recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month', timezone, isRecurring: true })
|
||||
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
|
||||
expect(message).toBe('Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").')
|
||||
})
|
||||
|
||||
test('should return null for invalid recurrence string (recurring)', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid recurrence', timezone, isRecurring: true })
|
||||
expect(result).toBeNull()
|
||||
expect(message).toBe('Invalid recurrence rule.')
|
||||
})
|
||||
|
||||
test('should return specific error for unsupported hourly frequency', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every hour', timezone, isRecurring: true })
|
||||
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
|
||||
expect(message).toBe('Hourly frequency is not supported.')
|
||||
})
|
||||
|
||||
test('should return specific error for unsupported daily interval', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every 2 days', timezone, isRecurring: true })
|
||||
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
|
||||
expect(message).toBe('Daily frequency with intervals greater than 1 is not supported.')
|
||||
})
|
||||
|
||||
test('should handle predefined constants like "weekdays"', () => {
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'weekdays', timezone, isRecurring: true })
|
||||
expect(message).toBeNull()
|
||||
expect(result).toBeInstanceOf(RRule)
|
||||
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
|
||||
// Check the weekday property of the Weekday objects
|
||||
const weekdays = (result as RRule).origOptions.byweekday;
|
||||
const weekdayNumbers = weekdays
|
||||
? (Array.isArray(weekdays)
|
||||
? weekdays.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
|
||||
: [typeof weekdays === 'number' ? weekdays : (weekdays as Weekday).weekday])
|
||||
: [];
|
||||
expect(weekdayNumbers).toEqual([RRule.MO.weekday, RRule.TU.weekday, RRule.WE.weekday, RRule.TH.weekday, RRule.FR.weekday])
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
||||
const timezone = 'America/New_York'
|
||||
|
||||
// Non-recurring tests
|
||||
test('should format DateTime object (non-recurring)', () => {
|
||||
const dateTime = DateTime.fromISO('2024-07-16T00:00:00', { zone: timezone }) as DateTime<true>
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: dateTime, isRecurRule: false, timezone })
|
||||
// Expected format depends on locale, check for key parts
|
||||
expect(humanReadable).toContain('Jul 16, 2024')
|
||||
expect(humanReadable).toContain('Tue') // Tuesday
|
||||
})
|
||||
|
||||
test('should format ISO string (non-recurring)', () => {
|
||||
const isoString = '2024-07-16T00:00:00.000-04:00' // Example ISO string with offset
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: isoString, isRecurRule: false, timezone })
|
||||
expect(humanReadable).toContain('Jul 16, 2024')
|
||||
expect(humanReadable).toContain('Tue')
|
||||
})
|
||||
|
||||
test('should return "Initial Due" for null frequency (non-recurring)', () => {
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: false, timezone })
|
||||
// Check against the imported constant value
|
||||
expect(humanReadable).toBe(INITIAL_DUE)
|
||||
})
|
||||
|
||||
// Recurring tests
|
||||
test('should format RRule object (recurring)', () => {
|
||||
const rrule = new RRule({ freq: RRule.DAILY })
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rrule, isRecurRule: true, timezone })
|
||||
// rrule.toText() returns "every day" for daily rules
|
||||
expect(humanReadable).toBe('every day')
|
||||
})
|
||||
|
||||
test('should format RRule string (recurring)', () => {
|
||||
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
|
||||
expect(humanReadable).toBe('every week on Monday, Wednesday, Friday')
|
||||
})
|
||||
|
||||
test('should return "invalid" for invalid RRule string (recurring)', () => {
|
||||
const rruleStr = 'INVALID_RRULE'
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
|
||||
expect(humanReadable).toBe('invalid')
|
||||
})
|
||||
|
||||
test('should return "invalid" for null frequency (recurring)', () => {
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: true, timezone })
|
||||
expect(humanReadable).toBe('invalid')
|
||||
})
|
||||
|
||||
test('should return "invalid" for unexpected type (recurring)', () => {
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: 123 as unknown as ParsedResultType, isRecurRule: true, timezone })
|
||||
expect(humanReadable).toBe('invalid')
|
||||
})
|
||||
|
||||
test('should return "invalid" for unexpected type (non-recurring)', () => {
|
||||
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: new RRule({ freq: RRule.DAILY }) as unknown as ParsedResultType, isRecurRule: false, timezone })
|
||||
expect(humanReadable).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
204
lib/utils.ts
204
lib/utils.ts
@@ -2,8 +2,8 @@ import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||
import { datetime, RRule } from 'rrule'
|
||||
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types'
|
||||
import { DUE_MAP, INITIAL_DUE, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
|
||||
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import * as chrono from 'chrono-node'
|
||||
import _ from "lodash"
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -185,67 +185,125 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
|
||||
).length;
|
||||
}
|
||||
|
||||
export function getRRuleUTC(recurrenceRule: string) {
|
||||
return RRule.fromString(recurrenceRule); // this returns UTC
|
||||
}
|
||||
|
||||
export function parseNaturalLanguageRRule(ruleText: string) {
|
||||
ruleText = ruleText.trim()
|
||||
let rrule: RRule
|
||||
if (RECURRENCE_RULE_MAP[ruleText]) {
|
||||
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
|
||||
} else {
|
||||
rrule = RRule.fromText(ruleText)
|
||||
// Enhanced validation for weekly/monthly rules
|
||||
function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult {
|
||||
if (!rrule) {
|
||||
return { result: null, message: 'Invalid recurrence rule.' };
|
||||
}
|
||||
|
||||
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
|
||||
return rrule
|
||||
}
|
||||
|
||||
export function parseRRule(ruleText: string) {
|
||||
ruleText = ruleText.trim()
|
||||
let rrule: RRule
|
||||
if (RECURRENCE_RULE_MAP[ruleText]) {
|
||||
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
|
||||
} else {
|
||||
rrule = RRule.fromString(ruleText)
|
||||
const unsupportedReason = getUnsupportedRRuleReason(rrule);
|
||||
if (unsupportedReason) {
|
||||
return { result: rrule, message: unsupportedReason };
|
||||
}
|
||||
|
||||
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
|
||||
return rrule
|
||||
const options = rrule.origOptions;
|
||||
|
||||
if (options.freq === RRule.WEEKLY && (!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) {
|
||||
return { result: null, message: 'Please specify day(s) of the week (e.g., "every week on Mon, Wed").' };
|
||||
}
|
||||
|
||||
if (options.freq === RRule.MONTHLY &&
|
||||
(!options.bymonthday || !Array.isArray(options.bymonthday) || options.bymonthday.length === 0) &&
|
||||
(!options.bysetpos || !Array.isArray(options.bysetpos) || options.bysetpos.length === 0) && // Need to check bysetpos for rules like "last Friday"
|
||||
(!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) { // Need byweekday with bysetpos
|
||||
return { result: null, message: 'Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").' };
|
||||
}
|
||||
|
||||
return { result: rrule, message: null };
|
||||
}
|
||||
|
||||
export function serializeRRule(rrule: RRule) {
|
||||
// Convert a human-readable frequency (recurring or non-recurring) into a machine-readable one
|
||||
export function convertHumanReadableFrequencyToMachineReadable({ text, timezone, isRecurring = false }: { text: string, timezone: string, isRecurring?: boolean }): ParsedFrequencyResult {
|
||||
text = text.trim()
|
||||
|
||||
if (!isRecurring) {
|
||||
if (DUE_MAP[text]) {
|
||||
text = DUE_MAP[text]
|
||||
}
|
||||
const now = getNow({ timezone })
|
||||
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
|
||||
if (!due) return { result: null, message: 'Invalid due date.' }
|
||||
const result = due ? DateTime.fromJSDate(due).setZone(timezone) : null
|
||||
return { message: null, result: result ? (result.isValid ? result : null) : null }
|
||||
}
|
||||
|
||||
let rrule: RRule | null
|
||||
if (RECURRENCE_RULE_MAP[text]) {
|
||||
rrule = deserializeRRule(RECURRENCE_RULE_MAP[text])
|
||||
} else if (text.toLowerCase() === 'weekdays') {
|
||||
// Handle 'weekdays' specifically if not in the map
|
||||
rrule = new RRule({
|
||||
freq: RRule.WEEKLY,
|
||||
byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR]
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
rrule = RRule.fromText(text)
|
||||
} catch (error) {
|
||||
rrule = null
|
||||
}
|
||||
}
|
||||
return validateRecurrenceRule(rrule);
|
||||
}
|
||||
|
||||
// convert a machine-readable rrule **string** to an rrule object
|
||||
export function deserializeRRule(rruleStr: string): RRule | null {
|
||||
try {
|
||||
return RRule.fromString(rruleStr);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// convert a machine-readable rrule **object** to an rrule string
|
||||
export function serializeRRule(rrule: RRule | null): string {
|
||||
if (!rrule) return 'invalid'; // Handle null case explicitly
|
||||
return rrule.toString()
|
||||
}
|
||||
|
||||
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) {
|
||||
if (DUE_MAP[text]) {
|
||||
text = DUE_MAP[text]
|
||||
}
|
||||
const now = getNow({ timezone })
|
||||
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
|
||||
if (!due) throw Error('invalid rule')
|
||||
// return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
||||
return DateTime.fromJSDate(due).setZone(timezone)
|
||||
}
|
||||
|
||||
export function getFrequencyDisplayText(frequency: string | undefined, isRecurRule: boolean, timezone: string) {
|
||||
// Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one
|
||||
export function convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency,
|
||||
isRecurRule,
|
||||
timezone
|
||||
}: {
|
||||
frequency: ParsedResultType,
|
||||
isRecurRule: boolean,
|
||||
timezone: string
|
||||
}): string {
|
||||
if (isRecurRule) {
|
||||
try {
|
||||
return parseRRule((frequency) || INITIAL_RECURRENCE_RULE).toText();
|
||||
} catch {
|
||||
return 'invalid'
|
||||
if (!frequency) {
|
||||
return 'invalid'; // Handle null/undefined for recurring rules
|
||||
}
|
||||
if (frequency instanceof RRule) {
|
||||
return frequency.toText();
|
||||
} else if (typeof frequency === "string") {
|
||||
const parsedResult = deserializeRRule(frequency);
|
||||
return parsedResult?.toText() || 'invalid';
|
||||
} else {
|
||||
return 'invalid';
|
||||
}
|
||||
} else {
|
||||
// Handle non-recurring frequency
|
||||
if (!frequency) {
|
||||
return INITIAL_DUE
|
||||
// Use the imported constant for initial due date text
|
||||
return INITIAL_DUE;
|
||||
}
|
||||
if (typeof frequency === 'string') {
|
||||
return d2s({
|
||||
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
|
||||
timezone: timezone,
|
||||
format: DateTime.DATE_MED_WITH_WEEKDAY
|
||||
});
|
||||
} else if (frequency instanceof DateTime) {
|
||||
return d2s({
|
||||
dateTime: frequency,
|
||||
timezone: timezone,
|
||||
format: DateTime.DATE_MED_WITH_WEEKDAY
|
||||
});
|
||||
} else {
|
||||
return 'invalid';
|
||||
}
|
||||
return d2s({
|
||||
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
|
||||
timezone: timezone,
|
||||
format: DateTime.DATE_MED_WITH_WEEKDAY
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,13 +332,8 @@ export function isHabitDue({
|
||||
const endOfDay = date.setZone(timezone).endOf('day')
|
||||
|
||||
const ruleText = habit.frequency
|
||||
let rrule
|
||||
try {
|
||||
rrule = parseRRule(ruleText)
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse rrule for habit: ${habit.id} ${habit.name}`)
|
||||
return false
|
||||
}
|
||||
const rrule = deserializeRRule(ruleText)
|
||||
if (!rrule) return false
|
||||
rrule.origOptions.tzid = timezone
|
||||
rrule.options.tzid = rrule.origOptions.tzid
|
||||
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
|
||||
@@ -321,7 +374,7 @@ export function getHabitFreq(habit: Habit): Freq {
|
||||
// don't support recurring task yet
|
||||
return 'daily'
|
||||
}
|
||||
const rrule = parseRRule(habit.frequency)
|
||||
const rrule = RRule.fromString(habit.frequency)
|
||||
const freq = rrule.origOptions.freq
|
||||
switch (freq) {
|
||||
case RRule.DAILY: return 'daily'
|
||||
@@ -335,11 +388,32 @@ export function getHabitFreq(habit: Habit): Freq {
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnsupportedRRule(rrule: RRule): boolean {
|
||||
const freq = rrule.origOptions.freq
|
||||
return freq === RRule.HOURLY || freq === RRule.MINUTELY || freq === RRule.SECONDLY
|
||||
/**
|
||||
* Checks if an RRule is unsupported and returns the reason.
|
||||
* @param rrule The RRule object to check.
|
||||
* @returns A string message explaining why the rule is unsupported, or null if it's supported.
|
||||
*/
|
||||
export function getUnsupportedRRuleReason(rrule: RRule): string | null {
|
||||
const freq = rrule.origOptions.freq;
|
||||
const interval = rrule.origOptions.interval || 1; // RRule defaults interval to 1
|
||||
|
||||
if (freq === RRule.HOURLY) {
|
||||
return 'Hourly frequency is not supported.';
|
||||
}
|
||||
if (freq === RRule.MINUTELY) {
|
||||
return 'Minutely frequency is not supported.';
|
||||
}
|
||||
if (freq === RRule.SECONDLY) {
|
||||
return 'Secondly frequency is not supported.';
|
||||
}
|
||||
if (freq === RRule.DAILY && interval > 1) {
|
||||
return 'Daily frequency with intervals greater than 1 is not supported.';
|
||||
}
|
||||
|
||||
return null; // Rule is supported
|
||||
}
|
||||
|
||||
|
||||
// play sound (client side only, must be run in browser)
|
||||
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
|
||||
const audio = new Audio(soundPath)
|
||||
@@ -360,10 +434,10 @@ export const openWindow = (url: string): boolean => {
|
||||
|
||||
export function deepMerge<T>(a: T, b: T) {
|
||||
return _.merge(a, b, (x: unknown, y: unknown) => {
|
||||
if (_.isArray(a)) {
|
||||
return a.concat(b)
|
||||
}
|
||||
})
|
||||
if (_.isArray(a)) {
|
||||
return a.concat(b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function checkPermission(
|
||||
@@ -372,7 +446,7 @@ export function checkPermission(
|
||||
action: 'write' | 'interact'
|
||||
): boolean {
|
||||
if (!permissions) return false
|
||||
|
||||
|
||||
return permissions.some(permission => {
|
||||
switch (resource) {
|
||||
case 'habit':
|
||||
|
||||
Reference in New Issue
Block a user