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:
5
.github/workflows/docker-publish.yml
vendored
5
.github/workflows/docker-publish.yml
vendored
@@ -52,13 +52,12 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
|
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
|
||||||
${{ steps.check-version.outputs.EXISTS == 'false' && 'dohsimpson/habittrove:latest' || '' }}
|
dohsimpson/habittrove:demo
|
||||||
dohsimpson/habittrove:dev
|
|
||||||
|
|
||||||
deploy-demo:
|
deploy-demo:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-and-push
|
needs: build-and-push
|
||||||
# demo tracks the latest tag
|
# demo tracks the demo tag
|
||||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
if: needs.build-and-push.outputs.EXISTS == 'false'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.2.6
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* support weekly / monthly intervals for recurring frequency (#99)
|
||||||
|
* show error when frequency is unsupported (#56)
|
||||||
|
* add task / habit button in habit view
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* make user select modal scrollable
|
||||||
|
|
||||||
## Version 0.2.5
|
## Version 0.2.5
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS base
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM --platform=$BUILDPLATFORM base AS deps
|
FROM base AS deps
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -19,7 +19,7 @@ RUN \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
FROM --platform=$BUILDPLATFORM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import data from '@emoji-mart/data'
|
import data from '@emoji-mart/data'
|
||||||
import Picker from '@emoji-mart/react'
|
import Picker from '@emoji-mart/react'
|
||||||
import { Habit, SafeUser } from '@/lib/types'
|
import { Habit, SafeUser } from '@/lib/types'
|
||||||
import { d2s, d2t, getFrequencyDisplayText, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
|
||||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||||
import * as chrono from 'chrono-node';
|
import * as chrono from 'chrono-node';
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
@@ -43,30 +43,41 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||||
const isRecurRule = !isTask
|
const isRecurRule = !isTask
|
||||||
const origRuleText = getFrequencyDisplayText(habit?.frequency, isRecurRule, settings.system.timezone)
|
// Initialize ruleText with the actual frequency string or default, not the display text
|
||||||
const [ruleText, setRuleText] = useState<string>(origRuleText)
|
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
|
||||||
|
frequency: habit.frequency,
|
||||||
|
isRecurRule,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
||||||
|
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||||
const { currentUser } = useHelpers()
|
const { currentUser } = useHelpers()
|
||||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||||
|
const [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const users = usersData.users
|
const users = usersData.users
|
||||||
|
|
||||||
function getFrequencyUpdate() {
|
function getFrequencyUpdate() {
|
||||||
if (ruleText === origRuleText && habit?.frequency) {
|
if (ruleText === initialRuleText && habit?.frequency) {
|
||||||
return habit.frequency
|
// If text hasn't changed and original frequency exists, return it
|
||||||
|
return habit.frequency;
|
||||||
}
|
}
|
||||||
if (isRecurRule) {
|
|
||||||
const parsedRule = parseNaturalLanguageRRule(ruleText)
|
const parsedResult = convertHumanReadableFrequencyToMachineReadable({
|
||||||
return serializeRRule(parsedRule)
|
|
||||||
} else {
|
|
||||||
const parsedDate = parseNaturalLanguageDate({
|
|
||||||
text: ruleText,
|
text: ruleText,
|
||||||
|
timezone: settings.system.timezone,
|
||||||
|
isRecurring: isRecurRule
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsedResult.result) {
|
||||||
|
return isRecurRule
|
||||||
|
? serializeRRule(parsedResult.result as RRule)
|
||||||
|
: d2t({
|
||||||
|
dateTime: parsedResult.result as DateTime,
|
||||||
timezone: settings.system.timezone
|
timezone: settings.system.timezone
|
||||||
})
|
});
|
||||||
return d2t({
|
} else {
|
||||||
dateTime: parsedDate,
|
return 'invalid';
|
||||||
timezone: settings.system.timezone
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +157,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
<Label htmlFor="recurrence" className="text-right">
|
<Label htmlFor="recurrence" className="text-right">
|
||||||
When *
|
When *
|
||||||
</Label>
|
</Label>
|
||||||
|
{/* date input (task) */}
|
||||||
<div className="col-span-3 space-y-2">
|
<div className="col-span-3 space-y-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -189,16 +201,26 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
|
{/* rrule input (habit) */}
|
||||||
<span>
|
<div className="col-start-2 col-span-3 text-sm">
|
||||||
{(() => {
|
{(() => {
|
||||||
try {
|
let displayText = '';
|
||||||
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
let errorMessage: string | null = null;
|
||||||
} catch (e: unknown) {
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||||
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
|
errorMessage = message;
|
||||||
}
|
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
||||||
})()}
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||||
|
{displayText}
|
||||||
</span>
|
</span>
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseRRule, d2s, getCompletionsForToday, isTaskOverdue, getFrequencyDisplayText } from '@/lib/utils'
|
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react'
|
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react'
|
||||||
@@ -105,7 +105,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-1">
|
||||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||||
When: {getFrequencyDisplayText(habit.frequency, isRecurRule, settings.system.timezone)}
|
When: {convertMachineReadableFrequencyToHumanReadable({
|
||||||
|
frequency: habit.frequency,
|
||||||
|
isRecurRule,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center mt-2">
|
<div className="flex items-center mt-2">
|
||||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||||
|
|||||||
@@ -45,9 +45,14 @@ export default function HabitList() {
|
|||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
{isTasksView ? 'My Tasks' : 'My Habits'}
|
{isTasksView ? 'My Tasks' : 'My Habits'}
|
||||||
</h1>
|
</h1>
|
||||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
<span>
|
||||||
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
|
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> {'Add Task'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='py-4'>
|
<div className='py-4'>
|
||||||
<ViewToggle />
|
<ViewToggle />
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function UserSelectionView({
|
|||||||
onCreateUser: () => void
|
onCreateUser: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-4 p-2">
|
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
||||||
{users
|
{users
|
||||||
.filter(user => user.id !== currentUser?.id)
|
.filter(user => user.id !== currentUser?.id)
|
||||||
.map((user) => (
|
.map((user) => (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CheckSquare, Target } from "lucide-react"
|
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 INITIAL_DUE = 'today'
|
||||||
|
|
||||||
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
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 { uuid } from "./utils"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
export type UserId = string
|
export type UserId = string
|
||||||
|
|
||||||
@@ -188,3 +190,11 @@ export interface JotaiHydrateInitialValues {
|
|||||||
export interface ServerSettings {
|
export interface ServerSettings {
|
||||||
isDemo: boolean
|
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 {
|
import {
|
||||||
cn,
|
cn,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
@@ -17,12 +17,18 @@ import {
|
|||||||
isHabitDueToday,
|
isHabitDueToday,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
uuid,
|
uuid,
|
||||||
isTaskOverdue
|
isTaskOverdue,
|
||||||
|
deserializeRRule,
|
||||||
|
serializeRRule,
|
||||||
|
convertHumanReadableFrequencyToMachineReadable,
|
||||||
|
convertMachineReadableFrequencyToHumanReadable,
|
||||||
|
getUnsupportedRRuleReason
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { CoinTransaction } from './types'
|
import { CoinTransaction, ParsedResultType } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { RRule } from 'rrule';
|
import { RRule, Weekday } from 'rrule';
|
||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
|
import { INITIAL_DUE } from './constants';
|
||||||
|
|
||||||
describe('cn utility', () => {
|
describe('cn utility', () => {
|
||||||
test('should merge class names correctly', () => {
|
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', () => {
|
describe('isTaskOverdue', () => {
|
||||||
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
|
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
|
||||||
id: 'test-habit',
|
id: 'test-habit',
|
||||||
@@ -652,3 +711,248 @@ describe('isHabitDue', () => {
|
|||||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
166
lib/utils.ts
166
lib/utils.ts
@@ -2,8 +2,8 @@ import { clsx, type ClassValue } from "clsx"
|
|||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||||
import { datetime, RRule } from 'rrule'
|
import { datetime, RRule } from 'rrule'
|
||||||
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types'
|
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
|
||||||
import { DUE_MAP, INITIAL_DUE, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
|
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import _ from "lodash"
|
import _ from "lodash"
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
@@ -185,67 +185,125 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRRuleUTC(recurrenceRule: string) {
|
// Enhanced validation for weekly/monthly rules
|
||||||
return RRule.fromString(recurrenceRule); // this returns UTC
|
function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult {
|
||||||
|
if (!rrule) {
|
||||||
|
return { result: null, message: 'Invalid recurrence rule.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseNaturalLanguageRRule(ruleText: string) {
|
const unsupportedReason = getUnsupportedRRuleReason(rrule);
|
||||||
ruleText = ruleText.trim()
|
if (unsupportedReason) {
|
||||||
let rrule: RRule
|
return { result: rrule, message: unsupportedReason };
|
||||||
if (RECURRENCE_RULE_MAP[ruleText]) {
|
|
||||||
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
|
|
||||||
} else {
|
|
||||||
rrule = RRule.fromText(ruleText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
|
const options = rrule.origOptions;
|
||||||
return rrule
|
|
||||||
|
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").' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRRule(ruleText: string) {
|
if (options.freq === RRule.MONTHLY &&
|
||||||
ruleText = ruleText.trim()
|
(!options.bymonthday || !Array.isArray(options.bymonthday) || options.bymonthday.length === 0) &&
|
||||||
let rrule: RRule
|
(!options.bysetpos || !Array.isArray(options.bysetpos) || options.bysetpos.length === 0) && // Need to check bysetpos for rules like "last Friday"
|
||||||
if (RECURRENCE_RULE_MAP[ruleText]) {
|
(!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) { // Need byweekday with bysetpos
|
||||||
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
|
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").' };
|
||||||
} else {
|
|
||||||
rrule = RRule.fromString(ruleText)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
|
return { result: rrule, message: null };
|
||||||
return rrule
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeRRule(rrule: RRule) {
|
// Convert a human-readable frequency (recurring or non-recurring) into a machine-readable one
|
||||||
return rrule.toString()
|
export function convertHumanReadableFrequencyToMachineReadable({ text, timezone, isRecurring = false }: { text: string, timezone: string, isRecurring?: boolean }): ParsedFrequencyResult {
|
||||||
}
|
text = text.trim()
|
||||||
|
|
||||||
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) {
|
if (!isRecurring) {
|
||||||
if (DUE_MAP[text]) {
|
if (DUE_MAP[text]) {
|
||||||
text = DUE_MAP[text]
|
text = DUE_MAP[text]
|
||||||
}
|
}
|
||||||
const now = getNow({ timezone })
|
const now = getNow({ timezone })
|
||||||
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
|
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
|
||||||
if (!due) throw Error('invalid rule')
|
if (!due) return { result: null, message: 'Invalid due date.' }
|
||||||
// return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
const result = due ? DateTime.fromJSDate(due).setZone(timezone) : null
|
||||||
return DateTime.fromJSDate(due).setZone(timezone)
|
return { message: null, result: result ? (result.isValid ? result : null) : null }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFrequencyDisplayText(frequency: string | undefined, isRecurRule: boolean, timezone: string) {
|
let rrule: RRule | null
|
||||||
if (isRecurRule) {
|
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 {
|
try {
|
||||||
return parseRRule((frequency) || INITIAL_RECURRENCE_RULE).toText();
|
rrule = RRule.fromText(text)
|
||||||
} catch {
|
} catch (error) {
|
||||||
return 'invalid'
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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 {
|
} else {
|
||||||
|
// Handle non-recurring frequency
|
||||||
if (!frequency) {
|
if (!frequency) {
|
||||||
return INITIAL_DUE
|
// Use the imported constant for initial due date text
|
||||||
|
return INITIAL_DUE;
|
||||||
}
|
}
|
||||||
|
if (typeof frequency === 'string') {
|
||||||
return d2s({
|
return d2s({
|
||||||
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
|
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
|
||||||
timezone: timezone,
|
timezone: timezone,
|
||||||
format: DateTime.DATE_MED_WITH_WEEKDAY
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,13 +332,8 @@ export function isHabitDue({
|
|||||||
const endOfDay = date.setZone(timezone).endOf('day')
|
const endOfDay = date.setZone(timezone).endOf('day')
|
||||||
|
|
||||||
const ruleText = habit.frequency
|
const ruleText = habit.frequency
|
||||||
let rrule
|
const rrule = deserializeRRule(ruleText)
|
||||||
try {
|
if (!rrule) return false
|
||||||
rrule = parseRRule(ruleText)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to parse rrule for habit: ${habit.id} ${habit.name}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
rrule.origOptions.tzid = timezone
|
rrule.origOptions.tzid = timezone
|
||||||
rrule.options.tzid = rrule.origOptions.tzid
|
rrule.options.tzid = rrule.origOptions.tzid
|
||||||
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
|
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
|
// don't support recurring task yet
|
||||||
return 'daily'
|
return 'daily'
|
||||||
}
|
}
|
||||||
const rrule = parseRRule(habit.frequency)
|
const rrule = RRule.fromString(habit.frequency)
|
||||||
const freq = rrule.origOptions.freq
|
const freq = rrule.origOptions.freq
|
||||||
switch (freq) {
|
switch (freq) {
|
||||||
case RRule.DAILY: return 'daily'
|
case RRule.DAILY: return 'daily'
|
||||||
@@ -335,10 +388,31 @@ export function getHabitFreq(habit: Habit): Freq {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUnsupportedRRule(rrule: RRule): boolean {
|
/**
|
||||||
const freq = rrule.origOptions.freq
|
* Checks if an RRule is unsupported and returns the reason.
|
||||||
return freq === RRule.HOURLY || freq === RRule.MINUTELY || freq === RRule.SECONDLY
|
* @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)
|
// play sound (client side only, must be run in browser)
|
||||||
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
|
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.5",
|
"version": "0.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
Reference in New Issue
Block a user