Skip to content

Commit e2cd3e3

Browse files
committed
Release v1.6.4 yearly recurrence controls
1 parent f440748 commit e2cd3e3

14 files changed

Lines changed: 462 additions & 120 deletions

‎TODO.md‎

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,6 @@
44

55
[ ] Check about indexing blog issues - ONGOING
66

7-
...
8-
9-
Launch v1.6.0 is complete: app and public site are live, billing is enabled,
10-
and the agent releases are published. Evidence: `status/app-status.md` and
11-
`status/agent-status.md`; private operations retain deployment details.
12-
13-
Remaining operational follow-ups:
14-
[ ] Confirm Dropbox broad-public production status in its owning App Console and the post-approval connection canary.
15-
[ ] Verify Search Console sitemap submission and indexing (retain the existing blog-indexing investigation above).
16-
[ ] Observe the first legitimate paid purchase and post-cutover hosted email; no extra synthetic charge/send is required.
17-
[ ] Finish external Google/Dropbox console removal of obsolete redirect URLs after Worker authority cleanup.
18-
197
[x] Actually validate the skill/MCP with our own OpenClaw - RE-TEST
208
- Test one use-case where the agent creates a task, starts the timer, go work on the actual task, stop the timer when it finished
219
- We might need to make this flow part of the skill for people that want to use tasktime pro for task & time management
@@ -31,19 +19,10 @@ Remaining operational follow-ups:
3119
[ ] Glama
3220
[ ] MCP.so
3321

34-
[ ] Think about publishing an official supported chatGPT plugin
22+
[ ] Setup chatGPT work as we did for DebugBundle
23+
[ ] Keep publishing blog posts related to tasktime pro with the real world, including usage with agents and all agents including ChatGPT/Codex, Claude/Claude Code, Gemini, Muse, Copilot, etc.
3524

36-
[ ] Focus mode - think how we can do this best.
37-
- Today we work on multiple projects or tasks at a time because a lot of the work is managed by AI. With this focus mode, when I enter it, I imagine a super clean overlay on top of everything, with the options to attached existing tasks, or create new ones directly inline (can be assigned to projects too)
38-
- These are the tasks you plan to do today/next so they remain here until you check them off - once checked, they disappear from this space
39-
- We can nicely separate them based on project - the ones on "top" have no project assigned
40-
- This view might be the best view to contain pomodoro? Let me know
41-
We might bring some of this previous idea I had earlier into this too:
42-
[ ] Timed sessions with sound alerts - I want to work on this for 1hour
43-
- We must think were this should be placed, as a setting, or a global option in a project for example, and we choose which task we want to work on
44-
[ ] This can also be an alert reminder settings in account that when a time passes a certain amount, we ping with a sound, and when we have push notifications, also have that choice
45-
- Pomodoro? Is this the same and does it apply here?
46-
I would perhaps just take the subtle sound effect for a ping option that can go on every 20, 30, 40, 60 minutes. As a gentle reminder that you're in focus mode and that a timer is ongoing
25+
[ ] Think about publishing an official supported chatGPT plugin
4726

4827
[ ] Improve reports overview with some useful charts - see existing mocks in TaskTime folder
4928

@@ -71,9 +50,21 @@ I would perhaps just take the subtle sound effect for a ping option that can go
7150

7251
## Ideas
7352

53+
[ ] Focus mode - think how we can do this best.
54+
- Today we work on multiple projects or tasks at a time because a lot of the work is managed by AI. With this focus mode, when I enter it, I imagine a super clean overlay on top of everything, with the options to attached existing tasks, or create new ones directly inline (can be assigned to projects too)
55+
- These are the tasks you plan to do today/next so they remain here until you check them off - once checked, they disappear from this space
56+
- We can nicely separate them based on project - the ones on "top" have no project assigned
57+
- This view might be the best view to contain pomodoro? Let me know
58+
We might bring some of this previous idea I had earlier into this too:
59+
[ ] Timed sessions with sound alerts - I want to work on this for 1hour
60+
- We must think were this should be placed, as a setting, or a global option in a project for example, and we choose which task we want to work on
61+
[ ] This can also be an alert reminder settings in account that when a time passes a certain amount, we ping with a sound, and when we have push notifications, also have that choice
62+
- Pomodoro? Is this the same and does it apply here?
63+
I would perhaps just take the subtle sound effect for a ping option that can go on every 20, 30, 40, 60 minutes. As a gentle reminder that you're in focus mode and that a timer is ongoing
64+
7465
[ ] Global search
7566

76-
[ ] Include theme color options in settings, default could be neutral
67+
[ ] Include theme color options in settings, default could be neutral and include a bit more this color throughout, for example in nav icons and maybe certain primary buttons
7768

7869
[ ] Keep track of invoices that you downloaded - so perhaps have a "downloaded" check icon or something
7970

@@ -82,4 +73,4 @@ I would perhaps just take the subtle sound effect for a ping option that can go
8273
[ ] Task Templates - Create “global” tasks which are assigned a category/tag and these can be assigned to all projects for that category by default (or at a click of a button → import default tasks for this project category)
8374
[ ] This would be a button under projects page “Create task templates”, then when creating a new project, we can choose a task template to be added
8475

85-
[ ] When the tab is not focused and we're in the dashboard, let's fade the view and leave the To Do Today widget the area of attention
76+
[ ] When the tab is not focused and we're in the dashboard, let's fade the view and leave the To Do Today widget the area of attention

‎e2e/account-task-polish.smoke.spec.js‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,39 @@ test('saving an open task editor preserves a newer pause and its active timer',
359359
});
360360
});
361361

362+
test('creates a yearly task from quick create and retains its date after reload', async ({ page }) => {
363+
await seed(page);
364+
await page.getByRole('button', { name: 'Open quick create menu' }).click();
365+
await page.getByRole('menuitem', { name: 'New Task' }).click();
366+
367+
const taskDialog = page.getByRole('dialog', { name: 'New Task' });
368+
await taskDialog.getByLabel('Task Title').fill('Annual license review');
369+
await taskDialog.getByRole('button', { name: 'Set repeat' }).click();
370+
371+
const repeatDialog = page.getByRole('dialog', { name: 'Repeat' });
372+
await repeatDialog.getByRole('button', { name: 'Every Year' }).click();
373+
await repeatDialog.getByRole('button', { name: 'May' }).click();
374+
await repeatDialog.getByRole('spinbutton', { name: 'Yearly day' }).fill('12');
375+
await repeatDialog.getByRole('button', { name: 'Apply' }).click();
376+
await expect(taskDialog.getByRole('button', { name: 'Yearly (May 12)' })).toBeVisible();
377+
await taskDialog.getByRole('button', { name: 'Create', exact: true }).click();
378+
379+
const readTask = () => page.evaluate(() => Array.from(
380+
window.__TASKTIME_STORE__.tasks.values(), task => task.toJSON(),
381+
).find(task => task.title === 'Annual license review'));
382+
await expect.poll(readTask).toMatchObject({
383+
recurring: { type: 'yearly', yearlyDate: '2023-05-12' },
384+
startDate: null,
385+
});
386+
await page.evaluate(() => window.__TASKTIME_STORE__.docManager.flushPersistence());
387+
await page.reload();
388+
await expect(page.getByTestId('dashboard-hours-chart')).toBeVisible();
389+
await expect.poll(readTask).toMatchObject({
390+
recurring: { type: 'yearly', yearlyDate: '2023-05-12' },
391+
startDate: null,
392+
});
393+
});
394+
362395
test('uses separate category forms and displays their original color in both themes', async ({ page }) => {
363396
await seed(page);
364397
await page.goto('/expenses');

‎e2e/expenses.smoke.spec.js‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,61 @@ import { expensesHeadingName, openExpensesPage } from './helpers/tasktime.js';
33

44
test.describe('Expenses smoke', () => {
55

6+
test('keeps yearly selection inside the repeat dialog and saves the expense', async ({ page }) => {
7+
await page.addInitScript(() => localStorage.setItem('tasktime-dark-mode', 'true'));
8+
9+
for (const viewport of [{ width: 320, height: 700 }, { width: 667, height: 375 }, { width: 1440, height: 900 }]) {
10+
await page.setViewportSize(viewport);
11+
await openExpensesPage(page);
12+
await page.getByRole('button', { name: 'New Expense', exact: true }).click();
13+
14+
const expenseDialog = page.getByRole('dialog', { name: 'New Expense' });
15+
await expenseDialog.getByRole('combobox', { name: 'Expense type' }).click();
16+
await page.getByRole('option', { name: 'Recurring', exact: true }).click();
17+
await expenseDialog.getByRole('button', { name: 'Monthly (1st)' }).click();
18+
19+
const repeatDialog = page.getByRole('dialog', { name: 'Repeat' });
20+
await repeatDialog.getByRole('button', { name: 'Every Year' }).click();
21+
await expect(repeatDialog.getByRole('button', { name: 'January' })).toBeVisible();
22+
await expect(repeatDialog.getByRole('button', { name: 'December' })).toBeVisible();
23+
await expect(repeatDialog.getByRole('spinbutton', { name: 'Yearly day' })).toBeVisible();
24+
await expect(repeatDialog.getByRole('listbox')).toHaveCount(0);
25+
await repeatDialog.screenshot({ path: `test-results/yearly-repeat-${viewport.width}.png` });
26+
27+
const bounds = await repeatDialog.boundingBox();
28+
expect(bounds.y).toBeGreaterThanOrEqual(0);
29+
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height + 1);
30+
if (viewport.height < 400) {
31+
expect(await repeatDialog.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
32+
}
33+
34+
await repeatDialog.getByRole('button', { name: 'December' }).click();
35+
await repeatDialog.getByRole('spinbutton', { name: 'Yearly day' }).fill('31');
36+
await repeatDialog.getByRole('button', { name: 'Apply' }).click();
37+
await expect(expenseDialog.getByRole('button', { name: 'Yearly (Dec 31)' })).toBeVisible();
38+
if (viewport.width !== 1440) {
39+
await expenseDialog.getByRole('button', { name: 'Cancel', exact: true }).click();
40+
continue;
41+
}
42+
43+
await expenseDialog.getByPlaceholder('Enter expense title').fill('Yearly renewal');
44+
await expenseDialog.getByLabel(/Amount/i).fill('12.34');
45+
await expenseDialog.getByRole('button', { name: 'Create Expense' }).click();
46+
await expect(expenseDialog).not.toBeVisible();
47+
48+
const readRecurrence = () => page.evaluate(() => Array.from(
49+
window.__TASKTIME_STORE__.expenseRecurrences.values(), recurrence => recurrence.toJSON(),
50+
).find(recurrence => recurrence.title === 'Yearly renewal'));
51+
await expect.poll(readRecurrence).toMatchObject({ repeat: 'yearly' });
52+
expect((await readRecurrence()).startDate).toMatch(/-12-31$/);
53+
await page.evaluate(() => window.__TASKTIME_STORE__.docManager.flushPersistence());
54+
await page.reload();
55+
await expect(page.getByRole('heading', { name: expensesHeadingName })).toBeVisible();
56+
await expect.poll(readRecurrence).toMatchObject({ repeat: 'yearly' });
57+
expect((await readRecurrence()).startDate).toMatch(/-12-31$/);
58+
}
59+
});
60+
661
test('deletes a newly created expense and closes both dialogs without a false error', async ({ page }) => {
762
const title = `Delete check ${Date.now()}`;
863
await openExpensesPage(page);
@@ -145,3 +200,34 @@ test.describe('Expenses smoke', () => {
145200
await expect(reloadedPaidRow).toContainText('12.34');
146201
});
147202
});
203+
204+
test.describe('Yearly expense in a western timezone', () => {
205+
test.use({ timezoneId: 'America/Los_Angeles' });
206+
207+
test('keeps the selected local calendar day when saving a recurrence', async ({ page }) => {
208+
await openExpensesPage(page);
209+
await page.getByRole('button', { name: 'New Expense', exact: true }).click();
210+
211+
const expenseDialog = page.getByRole('dialog', { name: 'New Expense' });
212+
await expenseDialog.getByRole('combobox', { name: 'Expense type' }).click();
213+
await page.getByRole('option', { name: 'Recurring', exact: true }).click();
214+
await expenseDialog.getByRole('button', { name: 'Monthly (1st)' }).click();
215+
216+
const repeatDialog = page.getByRole('dialog', { name: 'Repeat' });
217+
await repeatDialog.getByRole('button', { name: 'Every Year' }).click();
218+
await repeatDialog.getByRole('button', { name: 'December' }).click();
219+
await repeatDialog.getByRole('spinbutton', { name: 'Yearly day' }).fill('31');
220+
await repeatDialog.getByRole('button', { name: 'Apply' }).click();
221+
await expect(expenseDialog.getByRole('button', { name: 'Yearly (Dec 31)' })).toBeVisible();
222+
223+
await expenseDialog.getByPlaceholder('Enter expense title').fill('Western timezone renewal');
224+
await expenseDialog.getByLabel(/Amount/i).fill('12.34');
225+
await expenseDialog.getByRole('button', { name: 'Create Expense' }).click();
226+
await expect(expenseDialog).not.toBeVisible();
227+
228+
const recurrence = await page.evaluate(() => Array.from(
229+
window.__TASKTIME_STORE__.expenseRecurrences.values(), value => value.toJSON(),
230+
).find(value => value.title === 'Western timezone renewal'));
231+
expect(recurrence.startDate).toMatch(/-12-31$/);
232+
});
233+
});

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "tasktime",
33
"private": true,
4-
"version": "1.6.3",
4+
"version": "1.6.4",
55
"license": "AGPL-3.0-only",
66
"type": "module",
77
"bin": {

‎spec/acceptance.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646

4747
- Users can manage clients, projects, tasks/subtasks, notes, planner attachments, and goals through the corresponding screens.
4848
- A subtask cannot be configured as recurring.
49+
- New and edited top-level tasks can repeat weekly, monthly, or yearly. A yearly
50+
schedule chooses a month and day and reopens with that saved date selected.
51+
Task and expense yearly dates stay selectable inside the Repeat dialog at
52+
phone and desktop sizes, with dialog scrolling in short landscape viewports;
53+
days outside the chosen month cannot be applied. An expense keeps the selected
54+
local calendar day in western timezones when its yearly recurrence is saved.
4955
- Confirmed task/project/client deletion includes lazy yearly entries and archive
5056
documents, removes matching records from all supported placements, and retains
5157
unrelated work and protected financial records. Missing cloud history asks for

‎spec/designs/work-and-time.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ Minimize the distance from identifying work to tracking it accurately.
1919

2020
## Recurrence controls and project settings
2121

22+
- The shared Repeat dialog offers weekly, monthly, and yearly schedules for
23+
top-level tasks; recurring expenses retain monthly and yearly choices. Yearly
24+
selection keeps month buttons and a validated day field inside the dialog,
25+
including a scrollable dialog on short viewports.
2226
- Disable recurrence and Enable recurrence appear only in recurring-task
2327
three-dot menus used by task details and list views. Disable uses a calendar-off
2428
icon and Enable uses a calendar-check icon, with the same alignment and spacing

‎src/components/modals/ExpenseModal.jsx‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { usePreferences } from '../../hooks/usePreferences.ts';
2828
import { usePaymentMethods } from '../../hooks/usePaymentMethods.ts';
2929
import { buildExpenseFromRecurrence } from '@/utils/expenseUtils';
3030
import { DEFAULT_CURRENCY } from '@/utils/currencyUtils.ts';
31-
import { toStorageDate } from '@/utils/dateUtils.ts';
31+
import { parseStoredDate, toStorageDate } from '@/utils/dateUtils.ts';
3232
import { parseOptionalNumberInput } from '@/utils/numberInputUtils.ts';
3333
import { getExpenseDeletionBlockReason } from '@/domain/expenses/expenseOperations';
3434

@@ -900,8 +900,7 @@ const ExpenseModal = ({
900900
};
901901

902902
const getNextMonthlyStartDate = (monthlyType, monthlyDay, todayValue) => {
903-
const resolvedToday = todayValue ? new Date(todayValue) : new Date();
904-
const todayDate = Number.isNaN(resolvedToday.getTime()) ? new Date() : resolvedToday;
903+
const todayDate = parseStoredDate(todayValue) || new Date();
905904
const year = todayDate.getFullYear();
906905
const month = todayDate.getMonth();
907906

@@ -915,10 +914,8 @@ const ExpenseModal = ({
915914
};
916915

917916
const getNextYearlyStartDate = (yearlyDate, todayValue) => {
918-
const resolvedToday = todayValue ? new Date(todayValue) : new Date();
919-
const todayDate = Number.isNaN(resolvedToday.getTime()) ? new Date() : resolvedToday;
920-
const baseDate = yearlyDate ? new Date(yearlyDate) : todayDate;
921-
const baseValid = Number.isNaN(baseDate.getTime()) ? todayDate : baseDate;
917+
const todayDate = parseStoredDate(todayValue) || new Date();
918+
const baseValid = parseStoredDate(yearlyDate) || todayDate;
922919
const year = todayDate.getFullYear();
923920
const month = baseValid.getMonth();
924921
const day = baseValid.getDate();

0 commit comments

Comments
 (0)