From 3fd562fec13ffe55e0f74fa58ced7d16afb9bd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danko=20Lu=C4=8Di=C4=87?= Date: Sun, 7 Sep 2025 20:21:08 +0200 Subject: [PATCH 1/7] Redesign to make the page more mobile friendly - add header - user's name - options button that will be used to access option menu - move grayscale colors into css variables for consistency and easy reuse --- src/App.svelte | 5 +- src/app.css | 44 ++++++++++-------- src/ui/Button.svelte | 14 +++++- src/ui/Header.svelte | 63 +++++++++++++++++++++++++ src/ui/Status.svelte | 1 + src/ui/UserForm.svelte | 25 +++++++--- src/ui/WorkDuration.svelte | 3 +- src/ui/WorkdayEvents.svelte | 5 +- src/ui/WorkdayForm.svelte | 92 ++++++++++++++++++++++++------------- tests/e2e.spec.ts | 80 ++++++++++++++++---------------- 10 files changed, 228 insertions(+), 104 deletions(-) create mode 100644 src/ui/Header.svelte diff --git a/src/App.svelte b/src/App.svelte index 9da338b..f12c4e3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,6 +5,7 @@ import WorkdayForm from "./ui/WorkdayForm.svelte"; import { getDatabase } from "./lib/database"; import Favicon from "./ui/Favicon.svelte"; + import Header from "./ui/Header.svelte"; let user: User | null = $state(null); @@ -37,9 +38,9 @@ {/if} -
-

Welcome {user ? user.settings.username : "to Work Hours Tracker"}

+
{user ? user.settings.username : "Work Hours Tracker"}
+
{#if !user} {:else} diff --git a/src/app.css b/src/app.css index d50213a..1c3b0ba 100644 --- a/src/app.css +++ b/src/app.css @@ -6,39 +6,47 @@ font-weight: 400; color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + + /* Taken from https://tailwindcss.com/docs/colors */ + --color-gray-950: oklch(14.5% 0 0); + --color-gray-900: oklch(20.5% 0 0); + --color-gray-800: oklch(26.9% 0 0); + --color-gray-700: oklch(37.1% 0 0); + --color-gray-600: oklch(43.9% 0 0); + --color-gray-500: oklch(55.6% 0 0); + --color-gray-400: oklch(70.8% 0 0); + --color-gray-300: oklch(87% 0 0); + --color-gray-200: oklch(92.2% 0 0); + --color-gray-100: oklch(97% 0 0); + --color-gray-50: oklch(98.5% 0 0); + + color: var(--color-gray-900); + background-color: var(--color-gray-100); } body { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; } -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +main { + width: 100%; + display: flex; + justify-content: center; + padding-left: 0.5rem; + padding-right: 0.5rem; } -@media (prefers-color-scheme: light) { +@media (prefers-color-scheme: dark) { :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; + color: var(--color-gray-100); + background-color: var(--color-gray-800); } button { - background-color: #f9f9f9; + background-color: var(--color-gray-700); } } diff --git a/src/ui/Button.svelte b/src/ui/Button.svelte index 54467b3..40260ce 100644 --- a/src/ui/Button.svelte +++ b/src/ui/Button.svelte @@ -10,8 +10,18 @@ diff --git a/src/ui/Header.svelte b/src/ui/Header.svelte new file mode 100644 index 0000000..e738fa1 --- /dev/null +++ b/src/ui/Header.svelte @@ -0,0 +1,63 @@ + + +
+

{@render children?.()}

+
+
+ + + + +
+
+
+ + diff --git a/src/ui/Status.svelte b/src/ui/Status.svelte index 8a74adb..c097fd8 100644 --- a/src/ui/Status.svelte +++ b/src/ui/Status.svelte @@ -22,6 +22,7 @@ .status { font-size: 1.3rem; font-weight: 700; + margin-bottom: 1rem; } .working { diff --git a/src/ui/UserForm.svelte b/src/ui/UserForm.svelte index 4c3020f..18629e0 100644 --- a/src/ui/UserForm.svelte +++ b/src/ui/UserForm.svelte @@ -24,7 +24,7 @@ } }} > -
+
-
+
-
- +
+
diff --git a/src/ui/WorkDuration.svelte b/src/ui/WorkDuration.svelte index a354561..775ffad 100644 --- a/src/ui/WorkDuration.svelte +++ b/src/ui/WorkDuration.svelte @@ -11,7 +11,6 @@ diff --git a/src/ui/WorkdayEvents.svelte b/src/ui/WorkdayEvents.svelte index fc14508..abe5b3b 100644 --- a/src/ui/WorkdayEvents.svelte +++ b/src/ui/WorkdayEvents.svelte @@ -42,8 +42,9 @@ diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index d6dd8f0..d0df5e6 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -12,7 +12,7 @@ test("first visit, full workday", async ({ page }) => { "/work-hours-tracker/favicon/initial.ico", ); await expect( - page.getByRole("heading", { name: "Welcome to Work Hours Tracker" }), + page.getByRole("heading", { name: "Work Hours Tracker" }), ).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByLabel("Daily paid break")).toBeVisible(); @@ -22,25 +22,25 @@ test("first visit, full workday", async ({ page }) => { // Fill in the initial form await page.getByLabel("Username").fill("Mark S"); await page.getByLabel("Daily paid break").fill("30"); - await page.getByText("Start tracking!").click(); + await page.getByText("Start tracking").click(); // Form is gone await expect(page.getByLabel("Username")).not.toBeVisible(); await expect(page.getByLabel("Daily paid break")).not.toBeVisible(); - await expect(page.getByText("Start tracking!")).not.toBeVisible(); + await expect(page.getByText("Start tracking")).not.toBeVisible(); // User work hours tracking interface is shown await expect( - page.getByRole("heading", { name: "Welcome Mark S" }), + page.getByRole("heading", { name: "Mark S" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeEnabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeDisabled(); await expect( - page.getByRole("button", { name: "End Workday" }), + page.getByRole("button", { name: "End Work" }), ).toBeDisabled(); await expect(page.getByText("Not working")).toBeVisible(); @@ -57,13 +57,13 @@ test("first visit, full workday", async ({ page }) => { // Workday starts at 8:05:00 await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); - await page.getByRole("button", { name: "Start Workday" }).click(); + await page.getByRole("button", { name: "Start Work" }).click(); await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeDisabled(); await expect(page.getByRole("button", { name: "Start Break" })).toBeEnabled(); - await expect(page.getByRole("button", { name: "End Workday" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeEnabled(); await expect(page.getByText("Working")).toBeVisible(); await expect(page.getByText("Working")).toHaveCSS( "color", @@ -79,10 +79,10 @@ test("first visit, full workday", async ({ page }) => { await page.getByRole("button", { name: "Start Break" }).click(); await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeDisabled(); await expect( - page.getByRole("button", { name: "End Workday" }), + page.getByRole("button", { name: "End Work" }), ).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), @@ -103,9 +103,9 @@ test("first visit, full workday", async ({ page }) => { await page.getByRole("button", { name: "End Break" }).click(); await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeDisabled(); - await expect(page.getByRole("button", { name: "End Workday" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Start Break" })).toBeVisible(); await expect( page.getByRole("button", { name: "End Break" }), @@ -113,7 +113,7 @@ test("first visit, full workday", async ({ page }) => { // End work at 16:05:00 await page.clock.setFixedTime(new Date(2025, 2, 2, 16, 5, 0)); - await page.getByRole("button", { name: "End Workday" }).click(); + await page.getByRole("button", { name: "End Work" }).click(); await expect( page.getByRole("heading", { name: "Are you sure?" }), ).toBeVisible(); @@ -148,13 +148,13 @@ test("first visit, full workday", async ({ page }) => { await expect(page.getByRole("button", { name: "Cancel" })).not.toBeVisible(); await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeDisabled(); await expect( - page.getByRole("button", { name: "End Workday" }), + page.getByRole("button", { name: "End Work" }), ).toBeDisabled(); await expect(page.getByText("Not working")).toBeVisible(); await expect(page.getByText("Not working")).toHaveCSS( @@ -170,7 +170,7 @@ test("first visit, full workday", async ({ page }) => { test("User data persists through reloads", async ({ page }) => { await page.goto("/"); await expect( - page.getByRole("heading", { name: "Welcome to Work Hours Tracker" }), + page.getByRole("heading", { name: "Work Hours Tracker" }), ).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByLabel("Daily paid break")).toBeVisible(); @@ -180,57 +180,57 @@ test("User data persists through reloads", async ({ page }) => { // Fill in the initial form await page.getByLabel("Username").fill("Helly R"); await page.getByLabel("Daily paid break").fill("45"); - await page.getByText("Start tracking!").click(); + await page.getByText("Start tracking").click(); await expect( - page.getByRole("heading", { name: "Welcome Helly R" }), + page.getByRole("heading", { name: "Helly R" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeEnabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeDisabled(); await expect( - page.getByRole("button", { name: "End Workday" }), + page.getByRole("button", { name: "End Work" }), ).toBeDisabled(); await page.reload(); await expect( - page.getByRole("heading", { name: "Welcome Helly R" }), + page.getByRole("heading", { name: "Helly R" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeEnabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeDisabled(); await expect( - page.getByRole("button", { name: "End Workday" }), + page.getByRole("button", { name: "End Work" }), ).toBeDisabled(); }); test("Tracking data persists through reloads", async ({ page }) => { await page.goto("/"); await expect( - page.getByRole("heading", { name: "Welcome to Work Hours Tracker" }), + page.getByRole("heading", { name: "Work Hours Tracker" }), ).toBeVisible(); // Fill in the initial form await page.getByLabel("Username").fill("Burt G"); await page.getByLabel("Daily paid break").fill("45"); - await page.getByText("Start tracking!").click(); + await page.getByText("Start tracking").click(); // 8:05:00 hours await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); - await page.getByRole("button", { name: "Start Workday" }).click(); + await page.getByRole("button", { name: "Start Work" }).click(); await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 35, 0)); await page.getByRole("button", { name: "Start Break" }).click(); // Buttons in correct state after starting the break. await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeDisabled(); await expect( - page.getByRole("button", { name: "End Workday" }), + page.getByRole("button", { name: "End Work" }), ).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), @@ -241,10 +241,10 @@ test("Tracking data persists through reloads", async ({ page }) => { // Buttons in correct state after starting the break even after reloading. await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeDisabled(); await expect( - page.getByRole("button", { name: "End Workday" }), + page.getByRole("button", { name: "End Work" }), ).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), @@ -263,18 +263,18 @@ test("Tracking data persists through reloads", async ({ page }) => { getCancelElement: async (page: Page) => await page.getByRole("alertdialog"), }, ].forEach(({ desc, getCancelElement }) => { - test(`Can close confirm end workday dialog by ${desc}`, async ({ page }) => { + test(`Can close confirm end work dialog by ${desc}`, async ({ page }) => { await page.goto("/"); // Fill in the initial form await page.getByLabel("Username").fill("Mark S"); await page.getByLabel("Daily paid break").fill("30"); - await page.getByText("Start tracking!").click(); + await page.getByText("Start tracking").click(); // Workday starts at 8:05:00 await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); - await page.getByRole("button", { name: "Start Workday" }).click(); + await page.getByRole("button", { name: "Start Work" }).click(); await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 35, 0)); - await page.getByRole("button", { name: "End Workday" }).click(); + await page.getByRole("button", { name: "End Work" }).click(); // Confirmation modal opens. await expect( @@ -290,13 +290,13 @@ test("Tracking data persists through reloads", async ({ page }) => { ).not.toBeVisible(); // Everything else unchanged await expect( - page.getByRole("button", { name: "Start Workday" }), + page.getByRole("button", { name: "Start Work" }), ).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeEnabled(); await expect( - page.getByRole("button", { name: "End Workday" }), + page.getByRole("button", { name: "End Work" }), ).toBeEnabled(); }); }); @@ -305,12 +305,12 @@ test("Display hours worked so far", async ({ page }) => { await page.goto("/"); await page.getByLabel("Username").fill("Mark S"); await page.getByLabel("Daily paid break").fill("30"); - await page.getByText("Start tracking!").click(); + await page.getByText("Start tracking").click(); await expect(page.getByText(/Work duration/)).not.toBeVisible(); await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); - await page.getByRole("button", { name: "Start Workday" }).click(); + await page.getByRole("button", { name: "Start Work" }).click(); await expect( page.getByText("Work duration: 0 hours, 0 minutes, 0 seconds"), @@ -329,7 +329,7 @@ test("Display hours worked so far", async ({ page }) => { ).toBeVisible(); await page.clock.setFixedTime(new Date(2025, 2, 2, 16, 10, 30)); - await page.getByRole("button", { name: "End Workday" }).click(); + await page.getByRole("button", { name: "End Work" }).click(); await page.getByRole("button", { name: "Yes, I'm done for today" }).click(); // Test fails without this delay in headless chromium browser for playwright // versions 1.52.0 or higher. From 74af742502326cea8b304da63139089683638107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danko=20Lu=C4=8Di=C4=87?= Date: Mon, 8 Sep 2025 01:18:39 +0200 Subject: [PATCH 2/7] Tweak confirmation modal styling for new redesign --- src/ui/ConfirmationModal.svelte | 23 +++++++--- tests/e2e.spec.ts | 80 +++++++++------------------------ 2 files changed, 36 insertions(+), 67 deletions(-) diff --git a/src/ui/ConfirmationModal.svelte b/src/ui/ConfirmationModal.svelte index a9008c5..51fe4e6 100644 --- a/src/ui/ConfirmationModal.svelte +++ b/src/ui/ConfirmationModal.svelte @@ -11,13 +11,16 @@

{@render title()}

{@render children()}

- - +
+ + +
diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index d0df5e6..28690ed 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -30,18 +30,12 @@ test("first visit, full workday", async ({ page }) => { await expect(page.getByText("Start tracking")).not.toBeVisible(); // User work hours tracking interface is shown - await expect( - page.getByRole("heading", { name: "Mark S" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeEnabled(); + await expect(page.getByRole("heading", { name: "Mark S" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeEnabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeDisabled(); - await expect( - page.getByRole("button", { name: "End Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeDisabled(); await expect(page.getByText("Not working")).toBeVisible(); await expect(page.getByText("Not working")).toHaveCSS( @@ -59,9 +53,7 @@ test("first visit, full workday", async ({ page }) => { await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); await page.getByRole("button", { name: "Start Work" }).click(); - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Start Break" })).toBeEnabled(); await expect(page.getByRole("button", { name: "End Work" })).toBeEnabled(); await expect(page.getByText("Working")).toBeVisible(); @@ -78,12 +70,8 @@ test("first visit, full workday", async ({ page }) => { await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 35, 0)); await page.getByRole("button", { name: "Start Break" }).click(); - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeDisabled(); - await expect( - page.getByRole("button", { name: "End Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).not.toBeVisible(); @@ -102,9 +90,7 @@ test("first visit, full workday", async ({ page }) => { await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 5, 0)); await page.getByRole("button", { name: "End Break" }).click(); - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeDisabled(); await expect(page.getByRole("button", { name: "End Work" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Start Break" })).toBeVisible(); await expect( @@ -147,15 +133,11 @@ test("first visit, full workday", async ({ page }) => { ).not.toBeVisible(); await expect(page.getByRole("button", { name: "Cancel" })).not.toBeVisible(); - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeDisabled(); - await expect( - page.getByRole("button", { name: "End Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeDisabled(); await expect(page.getByText("Not working")).toBeVisible(); await expect(page.getByText("Not working")).toHaveCSS( "color", @@ -182,33 +164,21 @@ test("User data persists through reloads", async ({ page }) => { await page.getByLabel("Daily paid break").fill("45"); await page.getByText("Start tracking").click(); - await expect( - page.getByRole("heading", { name: "Helly R" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeEnabled(); + await expect(page.getByRole("heading", { name: "Helly R" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeEnabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeDisabled(); - await expect( - page.getByRole("button", { name: "End Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeDisabled(); await page.reload(); - await expect( - page.getByRole("heading", { name: "Helly R" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeEnabled(); + await expect(page.getByRole("heading", { name: "Helly R" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeEnabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).toBeDisabled(); - await expect( - page.getByRole("button", { name: "End Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeDisabled(); }); test("Tracking data persists through reloads", async ({ page }) => { @@ -226,12 +196,8 @@ test("Tracking data persists through reloads", async ({ page }) => { await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 35, 0)); await page.getByRole("button", { name: "Start Break" }).click(); // Buttons in correct state after starting the break. - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeDisabled(); - await expect( - page.getByRole("button", { name: "End Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).not.toBeVisible(); @@ -240,12 +206,8 @@ test("Tracking data persists through reloads", async ({ page }) => { await page.reload(); // Buttons in correct state after starting the break even after reloading. - await expect( - page.getByRole("button", { name: "Start Work" }), - ).toBeDisabled(); - await expect( - page.getByRole("button", { name: "End Work" }), - ).toBeDisabled(); + await expect(page.getByRole("button", { name: "Start Work" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeDisabled(); await expect( page.getByRole("button", { name: "Start Break" }), ).not.toBeVisible(); @@ -295,9 +257,7 @@ test("Tracking data persists through reloads", async ({ page }) => { await expect( page.getByRole("button", { name: "Start Break" }), ).toBeEnabled(); - await expect( - page.getByRole("button", { name: "End Work" }), - ).toBeEnabled(); + await expect(page.getByRole("button", { name: "End Work" })).toBeEnabled(); }); }); From 2f9ba63ba531e283a8de4e3d3fe4a5228671a178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danko=20Lu=C4=8Di=C4=87?= Date: Thu, 11 Sep 2025 01:12:00 +0200 Subject: [PATCH 3/7] - Calculate workday end in tracker - Allow changing current workday length and paid break duration through the tracker --- src/lib/tracker.test.ts | 252 +++++++++++++++++++++++++++++++++++++++- src/lib/tracker.ts | 205 ++++++++++++++++++-------------- 2 files changed, 370 insertions(+), 87 deletions(-) diff --git a/src/lib/tracker.test.ts b/src/lib/tracker.test.ts index 469e385..fed6ace 100644 --- a/src/lib/tracker.test.ts +++ b/src/lib/tracker.test.ts @@ -7,7 +7,12 @@ import { type Workday, type WorkdayEvent, } from "./tracker"; -import { subHours, subMinutes, subSeconds } from "date-fns"; +import { + differenceInSeconds, + subHours, + subMinutes, + subSeconds, +} from "date-fns"; /** * Default user starts fresh with no workdays. @@ -20,6 +25,7 @@ beforeEach(() => { settings: { username: "Mark S.", paidBreakDuration: 45, + workdayLength: 8, }, // Default user *must* start with zero workdays worked because some tests rely on this. trackingData: { workdays: [] }, @@ -47,6 +53,7 @@ test("tracker can start a break", () => { { events: [{ type: "start-workday", time: subHours(currentDate, 2) }], paidBreakDuration: 35, + workdayLength: 8, }, ], }; @@ -68,6 +75,7 @@ test("tracker cannot start a break if workday has not started", () => { { type: "end-workday", time: currentDate }, ], paidBreakDuration: 35, + workdayLength: 8, }, ], }; @@ -138,6 +146,7 @@ test("tracker can end a break", () => { { events: [{ type: "start-workday", time: subHours(currentDate, 2) }], paidBreakDuration: 35, + workdayLength: 8, }, ], }, @@ -158,6 +167,7 @@ test("workday can last after midnight the next day ", () => { { events: [{ type: "start-workday", time: subHours(currentDate, 24) }], paidBreakDuration: 35, + workdayLength: 7, }, ], }, @@ -178,6 +188,7 @@ test("tracker cannot start a new workday if previous one has not ended", () => { { events: [{ type: "start-workday", time: subHours(currentDate, 2) }], paidBreakDuration: 35, + workdayLength: 8, }, ], }, @@ -226,6 +237,7 @@ test("tracker can end the workday", () => { { events: [{ type: "start-workday", time: subHours(currentDate, 2) }], paidBreakDuration: 35, + workdayLength: 8, }, ], }, @@ -488,6 +500,7 @@ test(`getTimeWorked only returns time worked for last workday`, () => { { type: "end-workday", time: new Date() }, ], paidBreakDuration: 5, + workdayLength: 8, }, // We measure this workday. { @@ -500,6 +513,7 @@ test(`getTimeWorked only returns time worked for last workday`, () => { { type: "end-workday", time: new Date() }, ], paidBreakDuration: 5, + workdayLength: 8, }, ], }, @@ -526,6 +540,7 @@ test("tracker returns updated tracking data", () => { { events: [{ time: expectedStartDate, type: "start-workday" }], paidBreakDuration: 45, + workdayLength: 8, }, ], }); @@ -541,6 +556,7 @@ test("tracker returns updated tracking data", () => { { time: expectedBreakStartDate, type: "start-break" }, ], paidBreakDuration: 45, + workdayLength: 8, }, ], }); @@ -557,6 +573,7 @@ test("tracker returns updated tracking data", () => { { time: expectedBreakEndDate, type: "end-break" }, ], paidBreakDuration: 45, + workdayLength: 8, }, ], }); @@ -574,6 +591,7 @@ test("tracker returns updated tracking data", () => { { time: expectedEndDate, type: "end-workday" }, ], paidBreakDuration: 45, + workdayLength: 8, }, ], }); @@ -801,3 +819,235 @@ test("onChange should register a callback to be called whenever tracker state up expect(changelog[3].user.trackingData.workdays[0].events).toHaveLength(4); expect(changelog[3].type).toEqual("end-workday"); }); + +test("calculateWorkEndTime should return date object for when workday should end", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 4, 1, 8, 0, 0)); + const tracker = createTracker(defaultUser); + + tracker.startWorkday(); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 16, 0, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + // Advance by 1 hour. + vi.advanceTimersByTime(1000 * 60 * 60 * 1); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 16, 0, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + + // Take a 45 minute (paid) break. + tracker.startBreak(); + vi.advanceTimersByTime(1000 * 60 * 45); + tracker.endBreak(); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 16, 0, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + + // Take a 15 minute (now unpaid) break. + tracker.startBreak(); + vi.advanceTimersByTime(1000 * 60 * 15); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 16, 15, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + tracker.endBreak(); + + // Work for 6 more hours (7:45 total). + vi.advanceTimersByTime(1000 * 60 * 60 * 6); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 16, 15, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + + // Take a 2 hour break. + tracker.startBreak(); + vi.advanceTimersByTime(1000 * 60 * 60 * 2); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 18, 15, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + + tracker.endBreak(); + // Work to full 8 hours. + vi.advanceTimersByTime(1000 * 60 * 15); + tracker.endWorkday(); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 18, 15, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); +}); + +test("calculateWorkEndTime should count hours worked over the workday length", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 4, 1, 8, 0, 0)); + const tracker = createTracker(defaultUser); + + tracker.startWorkday(); + // Work for 2 hours. + vi.advanceTimersByTime(1000 * 60 * 60 * 2); + // Take one hour break. + tracker.startBreak(); + vi.advanceTimersByTime(1000 * 60 * 60 * 1); + tracker.endBreak(); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 16, 15, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + + // Work for 10 hours. + vi.advanceTimersByTime(1000 * 60 * 60 * 10); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 21, 0, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); +}); + +[ + { hours: 8, expectedEndWorkDate: new Date(2024, 4, 1, 16, 0, 0) }, + { hours: 4, expectedEndWorkDate: new Date(2024, 4, 1, 12, 0, 0) }, + { hours: 5.4, expectedEndWorkDate: new Date(2024, 4, 1, 13, 24, 0) }, +].forEach(({ hours, expectedEndWorkDate }) => { + test(`calculateWorkEndTime should return date ${hours}h from now if workday hasn't started yet`, () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 4, 1, 8, 0, 0)); + const tracker = createTracker({ + ...defaultUser, + settings: { ...defaultUser.settings, workdayLength: hours }, + }); + + expect( + differenceInSeconds(expectedEndWorkDate, tracker.calculateWorkEndTime()), + ).toEqual(0); + }); +}); + +test("changeWorkdayLength should change workday length for a new workday", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 4, 1, 8, 0, 0)); + const tracker = createTracker({ + ...defaultUser, + settings: { ...defaultUser.settings, workdayLength: 8 }, + }); + // Work for first day for 8 hours. + tracker.startWorkday(); + vi.advanceTimersByTime(1000 * 60 * 60 * 8); + tracker.endWorkday(); + // Advance to next day (08:00h) and start workday with new settings. + vi.advanceTimersByTime(1000 * 60 * 60 * 16); + + tracker.changeWorkdayLength(6); + + tracker.startWorkday(); + expect( + differenceInSeconds( + new Date(2024, 4, 2, 14, 0, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + expect(tracker.getTrackingData().workdays[1].workdayLength).toEqual(6); + // Previous workday length should not change. + expect(tracker.getTrackingData().workdays[0].workdayLength).toEqual(8); +}); + +test("changeWorkdayLength should change workday length for a workday already in progress", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 4, 1, 8, 0, 0)); + const tracker = createTracker({ + ...defaultUser, + settings: { ...defaultUser.settings, workdayLength: 8 }, + }); + tracker.startWorkday(); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 16, 0, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); + + tracker.changeWorkdayLength(5); + expect( + differenceInSeconds( + new Date(2024, 4, 1, 13, 0, 0), + tracker.calculateWorkEndTime(), + ), + ).toEqual(0); +}); + +test("changePaidBreakDuration should change workday length for a new workday", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 4, 1, 8, 0, 0)); + const tracker = createTracker({ + ...defaultUser, + settings: { ...defaultUser.settings, paidBreakDuration: 60 }, + }); + // Work for first day for 8 hours including 1 hour paid break. + tracker.startWorkday(); + vi.advanceTimersByTime(1000 * 60 * 60 * 1); + tracker.startBreak(); + vi.advanceTimersByTime(1000 * 60 * 60 * 1); + tracker.endBreak(); + vi.advanceTimersByTime(1000 * 60 * 60 * 6); + expect(tracker.getTimeWorked()).toEqual({ + hours: 8, + minutes: 0, + seconds: 0, + }); + tracker.endWorkday(); + // Advance to next day (08:00h) and start workday with new settings. + vi.advanceTimersByTime(1000 * 60 * 60 * 16); + + tracker.changePaidBreakDuration(30); + + tracker.startWorkday(); + expect(tracker.getTrackingData().workdays[1].paidBreakDuration).toEqual(30); + // Previous workday length should not change. + expect(tracker.getTrackingData().workdays[0].paidBreakDuration).toEqual(60); +}); + +test("changePaidBreakDuration should change workday length for a workday already in progress", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 4, 1, 8, 0, 0)); + const tracker = createTracker({ + ...defaultUser, + settings: { ...defaultUser.settings, paidBreakDuration: 60 }, + }); + tracker.startWorkday(); + vi.advanceTimersByTime(1000 * 60 * 60 * 1); + tracker.startBreak(); + vi.advanceTimersByTime(1000 * 60 * 60 * 1); + tracker.endBreak(); + expect(tracker.getTimeWorked()).toEqual({ + hours: 2, + minutes: 0, + seconds: 0, + }); + + tracker.changePaidBreakDuration(30); + + expect(tracker.getTimeWorked()).toEqual({ + hours: 1, + minutes: 30, + seconds: 0, + }); +}); diff --git a/src/lib/tracker.ts b/src/lib/tracker.ts index 7f560aa..eb1670e 100644 --- a/src/lib/tracker.ts +++ b/src/lib/tracker.ts @@ -1,4 +1,4 @@ -import { differenceInSeconds, isSameDay } from "date-fns"; +import { addSeconds, differenceInSeconds, isSameDay } from "date-fns"; import { getTimeWorkedFromSecondsWorked } from "./getTimeWorkedFromSeconds"; export type EventType = @@ -19,8 +19,11 @@ export interface WorkdayEvent { } export interface Workday { - paidBreakDuration: number; events: WorkdayEvent[]; + /** In minutes. */ + paidBreakDuration: number; + /** In hours. */ + workdayLength: number; } export interface TrackingData { @@ -30,6 +33,8 @@ export interface TrackingData { export interface Settings { username: string; paidBreakDuration: number; + // In hours. + workdayLength: number; } export interface Duration { @@ -53,6 +58,76 @@ interface Tracker { getTimeWorked(): Duration; onChange(handler: (user: User, type: EventType) => void): void; getCurrentWorkdayEvents(): WorkdayEvent[]; + calculateWorkEndTime(): Date; + changeWorkdayLength(newWorkdayLength: number): void; + changePaidBreakDuration(newPaidBreakDuration: number): void; +} + +function getSecondsWorked(currentWorkday: Workday | undefined): number { + if (currentWorkday === undefined) { + return 0; + } + + // Only start-workday event has occurred. + if (currentWorkday.events.length === 1) { + const currentEvent = currentWorkday.events[0]; + const secondsWorked = differenceInSeconds(new Date(), currentEvent.time); + + return secondsWorked; + } + + const secondsOnBreak = currentWorkday.events.reduce( + (secondsOnBreakSoFar, currentEvent, index, events) => { + const previousEvent = events[index - 1]; + if (currentEvent.type === "end-break") { + secondsOnBreakSoFar += differenceInSeconds( + currentEvent.time, + previousEvent.time, + ); + } + + // If break is ongoing when getTimeWorked method is called we add the + // seconds elapsed since the start of the break. + if (currentEvent.type === "start-break" && index === events.length - 1) { + secondsOnBreakSoFar += differenceInSeconds( + new Date(), + currentEvent.time, + ); + } + + return secondsOnBreakSoFar; + }, + 0, + ); + + const secondsWorked = + currentWorkday.events.reduce( + (secondsWorkedSoFar, currentEvent, index, events) => { + const previousEvent = events[index - 1]; + if ( + currentEvent.type === "start-break" || + currentEvent.type === "end-workday" + ) { + secondsWorkedSoFar += differenceInSeconds( + currentEvent.time, + previousEvent.time, + ); + } + + // If the last event is end-break, measure time elapsed since it occurred. + if (currentEvent.type === "end-break" && index === events.length - 1) { + secondsWorkedSoFar += differenceInSeconds( + new Date(), + currentEvent.time, + ); + } + + return secondsWorkedSoFar; + }, + 0, + ) + Math.min(currentWorkday.paidBreakDuration * 60, secondsOnBreak); + + return secondsWorked; } export function createTracker(user: User): Tracker { @@ -64,7 +139,7 @@ export function createTracker(user: User): Tracker { onChangeCallback = callback; }, startWorkday() { - if (hasWorkdayStarted(data)) { + if (hasWorkdayStarted(getLastWorkday(data))) { throw new Error( "Cannot start workday if current workday has not ended.", ); @@ -72,18 +147,20 @@ export function createTracker(user: User): Tracker { data.workdays.push({ paidBreakDuration: user.settings.paidBreakDuration, + workdayLength: user.settings.workdayLength, events: [{ type: "start-workday", time: new Date() }], }); + onChangeCallback(user, "start-workday"); }, startBreak() { - if (!hasWorkdayStarted(data)) { + const currentWorkday = getLastWorkday(data); + + if (!hasWorkdayStarted(currentWorkday)) { throw new Error("Workday has not started."); } - const currentWorkday = getLastWorkday(data); - - currentWorkday.events.push({ + currentWorkday!.events.push({ time: new Date(), type: "start-break", }); @@ -120,7 +197,7 @@ export function createTracker(user: User): Tracker { onChangeCallback(user, "end-workday"); }, hasWorkdayStarted() { - return hasWorkdayStarted(data); + return hasWorkdayStarted(getLastWorkday(data)); }, canStartWorkday() { const currentWorkday = getLastWorkday(data); @@ -134,7 +211,7 @@ export function createTracker(user: User): Tracker { return false; } - return !hasWorkdayStarted(data); + return !hasWorkdayStarted(currentWorkday); }, hasBreakStarted() { const currentWorkday = getLastWorkday(data); @@ -151,78 +228,8 @@ export function createTracker(user: User): Tracker { }, getTimeWorked() { const currentWorkday = getLastWorkday(data); - if (currentWorkday === undefined) { - return { hours: 0, minutes: 0, seconds: 0 }; - } - if (currentWorkday.events.length === 1) { - const currentEvent = currentWorkday.events[0]; - const secondsWorked = differenceInSeconds( - new Date(), - currentEvent.time, - ); - - return getTimeWorkedFromSecondsWorked(secondsWorked); - } - - const secondsOnBreak = currentWorkday.events.reduce( - (secondsOnBreakSoFar, currentEvent, index, events) => { - const previousEvent = events[index - 1]; - if (currentEvent.type === "end-break") { - secondsOnBreakSoFar += differenceInSeconds( - currentEvent.time, - previousEvent.time, - ); - } - - // If break is ongoing when getTimeWorked method is called we add the - // seconds elapsed since the start of the break. - if ( - currentEvent.type === "start-break" && - index === events.length - 1 - ) { - secondsOnBreakSoFar += differenceInSeconds( - new Date(), - currentEvent.time, - ); - } - - return secondsOnBreakSoFar; - }, - 0, - ); - - const secondsWorked = - currentWorkday.events.reduce( - (secondsWorkedSoFar, currentEvent, index, events) => { - const previousEvent = events[index - 1]; - if ( - currentEvent.type === "start-break" || - currentEvent.type === "end-workday" - ) { - secondsWorkedSoFar += differenceInSeconds( - currentEvent.time, - previousEvent.time, - ); - } - - // If the last event is end-break, measure time elapsed since it occurred. - if ( - currentEvent.type === "end-break" && - index === events.length - 1 - ) { - secondsWorkedSoFar += differenceInSeconds( - new Date(), - currentEvent.time, - ); - } - - return secondsWorkedSoFar; - }, - 0, - ) + Math.min(currentWorkday.paidBreakDuration * 60, secondsOnBreak); - - return getTimeWorkedFromSecondsWorked(secondsWorked); + return getTimeWorkedFromSecondsWorked(getSecondsWorked(currentWorkday)); }, getCurrentWorkdayEvents() { if (data.workdays.length === 0) { @@ -233,17 +240,43 @@ export function createTracker(user: User): Tracker { return workdayEvents; }, + calculateWorkEndTime() { + const currentWorkday = getLastWorkday(data); + const workdayLength = hasWorkdayStarted(currentWorkday) + ? currentWorkday!.workdayLength + : user.settings.workdayLength; + const workdayLengthInSeconds = workdayLength * 60 * 60; + const secondsWorked = getSecondsWorked(currentWorkday); + + if (secondsWorked <= workdayLengthInSeconds) { + return addSeconds(new Date(), workdayLengthInSeconds - secondsWorked); + } + + return new Date(); + }, + changeWorkdayLength(newWorkdayLength) { + user.settings.workdayLength = newWorkdayLength; + const currentWorkday = getLastWorkday(data); + if (hasWorkdayStarted(currentWorkday)) { + currentWorkday!.workdayLength = newWorkdayLength; + } + }, + changePaidBreakDuration(newPaidBreakDuration) { + user.settings.paidBreakDuration = newPaidBreakDuration; + const currentWorkday = getLastWorkday(data); + if (hasWorkdayStarted(currentWorkday)) { + currentWorkday!.paidBreakDuration = newPaidBreakDuration; + } + }, }; } -function hasWorkdayStarted(data: TrackingData) { - const lastWorkday = getLastWorkday(data); - - if (lastWorkday === undefined) { +function hasWorkdayStarted(workday: Workday | undefined) { + if (workday === undefined) { return false; } - const lastEvent = lastWorkday.events[lastWorkday.events.length - 1]; + const lastEvent = workday.events[workday.events.length - 1]; if (lastEvent === undefined) { return false; } @@ -251,6 +284,6 @@ function hasWorkdayStarted(data: TrackingData) { return lastEvent.type !== "end-workday"; } -function getLastWorkday(data: TrackingData): Workday { +function getLastWorkday(data: TrackingData): Workday | undefined { return data.workdays[data.workdays.length - 1]; } From f34fd7a1a9deea198ebd4d62cde0834c0cb1b8b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danko=20Lu=C4=8Di=C4=87?= Date: Thu, 11 Sep 2025 01:15:02 +0200 Subject: [PATCH 4/7] Add todo for e2e tests --- tests/e2e.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index 28690ed..d301c07 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -297,3 +297,10 @@ test("Display hours worked so far", async ({ page }) => { await page.reload(); await expect(page.getByText(/Work duration/)).not.toBeVisible(); }); + +test.skip("do not show the options button in header if user has not yet entered their data", () => {}); +test.skip("can change paid break duration through the options menu", () => {}); +test.skip("can change paid break duration through the options menu after workday started", () => {}); +test.skip("can see the current estimate of when workday ends", () => {}); +test.skip("can change workday length through the options menu", () => {}); +test.skip("can change workday length through the options menu after workday started", () => {}); From 5ebcf4d53cfb42718ff0e234429d7b1c9b503cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danko=20Lu=C4=8Di=C4=87?= Date: Mon, 15 Sep 2025 00:29:21 +0200 Subject: [PATCH 5/7] Add options form and estimated end work time - TODO: styling - updated playwright to latest just in case --- package-lock.json | 24 ++--- package.json | 2 +- src/App.svelte | 35 ++++++- src/ui/Header.svelte | 36 ++++--- src/ui/OptionsForm.svelte | 49 +++++++++ src/ui/UserForm.svelte | 15 +++ src/ui/WorkdayForm.svelte | 4 + tests/e2e.spec.ts | 204 ++++++++++++++++++++++++++++++++++++-- 8 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 src/ui/OptionsForm.svelte diff --git a/package-lock.json b/package-lock.json index b62e6f6..26fa4fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", - "@playwright/test": "^1.54.2", + "@playwright/test": "^1.55.0", "@sveltejs/vite-plugin-svelte": "^6.1.0", "@tsconfig/svelte": "^5.0.4", "@types/node": "^24.2.0", @@ -825,13 +825,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz", - "integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==", + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.54.2" + "playwright": "1.55.0" }, "bin": { "playwright": "cli.js" @@ -2996,13 +2996,13 @@ } }, "node_modules/playwright": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", - "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.54.2" + "playwright-core": "1.55.0" }, "bin": { "playwright": "cli.js" @@ -3015,9 +3015,9 @@ } }, "node_modules/playwright-core": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", - "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 443f086..e613524 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", - "@playwright/test": "^1.54.2", + "@playwright/test": "^1.55.0", "@sveltejs/vite-plugin-svelte": "^6.1.0", "@tsconfig/svelte": "^5.0.4", "@types/node": "^24.2.0", diff --git a/src/App.svelte b/src/App.svelte index f12c4e3..f2060a1 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,13 +1,15 @@

{@render children?.()}

-
- - - - -
+ {#if !hideOptionsButton} + + {/if}
@@ -49,15 +51,17 @@ margin-left: auto; } - .button { + button { width: 3rem; height: 3rem; padding: 0.25rem; border-radius: 0.375rem; + border-style: none; + background-color: transparent; } - .button:hover { + button:hover { color: var(--color-gray-700); - background-color: var(--color-gray-100); + background-color: var(--color-gray-200); } diff --git a/src/ui/OptionsForm.svelte b/src/ui/OptionsForm.svelte new file mode 100644 index 0000000..2b8ac10 --- /dev/null +++ b/src/ui/OptionsForm.svelte @@ -0,0 +1,49 @@ + + +
{ + e.preventDefault(); + onSubmit({ paidBreakDuration, workdayLength }); + }} +> + + + + +
diff --git a/src/ui/UserForm.svelte b/src/ui/UserForm.svelte index 18629e0..e1ac44f 100644 --- a/src/ui/UserForm.svelte +++ b/src/ui/UserForm.svelte @@ -6,6 +6,7 @@ let username = $state(""); let paidBreakDuration = $state(45); + let workdayLength = $state(8);
+
+ +
+
diff --git a/src/ui/WorkdayForm.svelte b/src/ui/WorkdayForm.svelte index 4851e47..661ff0b 100644 --- a/src/ui/WorkdayForm.svelte +++ b/src/ui/WorkdayForm.svelte @@ -6,6 +6,7 @@ import Status from "./Status.svelte"; import WorkdayEvents from "./WorkdayEvents.svelte"; import WorkDuration from "./WorkDuration.svelte"; + import { formatDate } from "date-fns"; const { user: userProp, onChange } = $props(); let user: User = $state(userProp); @@ -45,6 +46,9 @@ {#if tracker.hasWorkdayStarted()} +
+ Work ends at {formatDate(tracker.calculateWorkEndTime(), "HH:mm:ss")} (estimate) +
{/if}
diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index d301c07..daab5b9 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -16,17 +16,21 @@ test("first visit, full workday", async ({ page }) => { ).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByLabel("Daily paid break")).toBeVisible(); + await expect(page.getByLabel("Workday length")).toBeVisible(); await expect(page.getByLabel("Username")).toContainText(""); await expect(page.getByLabel("Daily paid break")).toContainText(""); + await expect(page.getByTitle("Options")).not.toBeVisible(); // Fill in the initial form await page.getByLabel("Username").fill("Mark S"); await page.getByLabel("Daily paid break").fill("30"); + await page.getByLabel("Workday length").fill("6"); await page.getByText("Start tracking").click(); // Form is gone await expect(page.getByLabel("Username")).not.toBeVisible(); await expect(page.getByLabel("Daily paid break")).not.toBeVisible(); + await expect(page.getByLabel("Workday length")).not.toBeVisible(); await expect(page.getByText("Start tracking")).not.toBeVisible(); // User work hours tracking interface is shown @@ -43,6 +47,8 @@ test("first visit, full workday", async ({ page }) => { "rgb(255, 0, 0)", ); + await expect(page.getByTitle("Options")).toBeVisible(); + // Favicon updated await expect(page.getByTestId("favicon")).toHaveAttribute( "href", @@ -298,9 +304,195 @@ test("Display hours worked so far", async ({ page }) => { await expect(page.getByText(/Work duration/)).not.toBeVisible(); }); -test.skip("do not show the options button in header if user has not yet entered their data", () => {}); -test.skip("can change paid break duration through the options menu", () => {}); -test.skip("can change paid break duration through the options menu after workday started", () => {}); -test.skip("can see the current estimate of when workday ends", () => {}); -test.skip("can change workday length through the options menu", () => {}); -test.skip("can change workday length through the options menu after workday started", () => {}); +test("can change paid break duration through the options menu after workday started", async ({ + page, +}) => { + await page.goto("/"); + await page.getByLabel("Username").fill("Mark S"); + await page.getByText("Start tracking").click(); + await expect(page.getByTitle("Options")).toBeVisible(); + // Start work and use the full paid break. + await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); + await page.getByRole("button", { name: "Start work" }).click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 5, 0)); + await page.getByRole("button", { name: "Start break" }).click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 55, 0)); + await page.getByRole("button", { name: "End break" }).click(); + await expect( + page.getByText("Work duration: 1 hours, 45 minutes, 0 seconds"), + ).toBeVisible(); + await expect(page.getByLabel("Daily paid break")).not.toBeVisible(); + + await page.getByTitle("Options").click(); + await expect(page.getByLabel("Daily paid break")).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Daily paid break" }), + ).toHaveValue("45"); // default + await page.getByLabel("Daily paid break").fill("35"); + await page.getByRole("button", { name: "Save" }).click(); + + // Form is closed. + await expect(page.getByLabel("Daily paid break")).not.toBeVisible(); + // Smaller paid break should reduce hours worked. + await expect( + page.getByText("Work duration: 1 hours, 35 minutes, 0 seconds"), + ).toBeVisible(); + // Test fails without this delay in headless chromium browser for playwright + // versions 1.52.0 or higher. + await delay(10); + await page.reload(); + await expect( + page.getByText("Work duration: 1 hours, 35 minutes, 0 seconds"), + "Changes should be saved in the actual database", + ).toBeVisible(); +}); + +test("can change paid break duration through the options menu before workday started", async ({ + page, +}) => { + await page.goto("/"); + await page.getByLabel("Username").fill("Mark S"); + await page.getByText("Start tracking").click(); + await expect(page.getByTitle("Options")).toBeVisible(); + + await page.getByTitle("Options").click(); + await expect(page.getByLabel("Daily paid break")).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Daily paid break" }), + ).toHaveValue("45"); // default + await page.getByLabel("Daily paid break").fill("35"); + await page.getByRole("button", { name: "Save" }).click(); + + // Form is closed. + await expect(page.getByLabel("Daily paid break")).not.toBeVisible(); + // Start work, work for one hour, then do a 50 minute break. + await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); + await page.getByRole("button", { name: "Start work" }).click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 5, 0)); + await page.getByRole("button", { name: "Start break" }).click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 55, 0)); + await page.getByRole("button", { name: "End break" }).click(); + // Smaller paid break should reduce hours worked. + await expect( + page.getByText("Work duration: 1 hours, 35 minutes, 0 seconds"), + ).toBeVisible(); + // Test fails without this delay in headless chromium browser for playwright + // versions 1.52.0 or higher. + await delay(10); + await page.reload(); + await expect( + page.getByText("Work duration: 1 hours, 35 minutes, 0 seconds"), + "Changes should be saved in the actual database", + ).toBeVisible(); +}); + +test("can see the current estimate of when the workday will end", async ({ + page, +}) => { + await page.goto("/"); + await page.getByLabel("Username").fill("Mark S"); + await expect(page.getByText(/Work ends at/)).not.toBeVisible(); + await page.getByText("Start tracking").click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); + await expect(page.getByText(/Work ends at/)).not.toBeVisible(); + await page.getByRole("button", { name: "Start work" }).click(); + await expect(page.getByText("Work ends at 16:05:00")).toBeVisible(); +}); + +test("can change workday length through the options menu before workday starts", async ({ + page, +}) => { + await page.goto("/"); + await page.getByLabel("Username").fill("Mark S"); + await expect(page.getByText(/Work ends at/)).not.toBeVisible(); + await page.getByText("Start tracking").click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); + await expect(page.getByText(/Work ends at/)).not.toBeVisible(); + + await page.getByTitle("Options").click(); + await expect(page.getByLabel("Workday length")).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Workday length" }), + ).toHaveValue("8"); // default + await page.getByLabel("Workday length").fill("6"); + await page.getByRole("button", { name: "Save" }).click(); + + await page.getByRole("button", { name: "Start work" }).click(); + await expect(page.getByText("Work ends at 14:05:00")).toBeVisible(); +}); + +test("can change workday length through the options menu after workday starts", async ({ + page, +}) => { + await page.goto("/"); + await page.getByLabel("Username").fill("Mark S"); + await page.getByText("Start tracking").click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); + await page.getByRole("button", { name: "Start work" }).click(); + + await page.getByTitle("Options").click(); + await expect(page.getByLabel("Workday length")).toBeVisible(); + await page.getByLabel("Workday length").fill("5"); + await page.getByRole("button", { name: "Save" }).click(); + + await expect(page.getByText("Work ends at 13:05:00")).toBeVisible(); +}); + +test("submit Options form without changing values", async ({ page }) => { + await page.goto("/"); + await page.getByLabel("Username").fill("Mark S"); + await page.getByText("Start tracking").click(); + + await saveUnchangedOptionsForm(page); + + await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 0, 0)); + await page.getByRole("button", { name: "Start work" }).click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 0, 0)); + await page.getByRole("button", { name: "Start break" }).click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 10, 0, 0)); + await page.getByRole("button", { name: "End break" }).click(); + + await expect(page.getByText("Work ends at 16:15:00")).toBeVisible(); + await expect( + page.getByText("Work duration: 1 hours, 45 minutes, 0 seconds"), + ).toBeVisible(); + + await saveUnchangedOptionsForm(page); + + await expect(page.getByText("Work ends at 16:15:00")).toBeVisible(); + await expect( + page.getByText("Work duration: 1 hours, 45 minutes, 0 seconds"), + ).toBeVisible(); +}); + +test("cancel Options form should close it without saving changes", async ({ + page, +}) => { + await page.goto("/"); + await page.getByLabel("Username").fill("Mark S"); + await page.getByText("Start tracking").click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 0, 0)); + await page.getByRole("button", { name: "Start work" }).click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 0, 0)); + await page.getByRole("button", { name: "Start break" }).click(); + await page.clock.setFixedTime(new Date(2025, 2, 2, 10, 0, 0)); + await page.getByRole("button", { name: "End break" }).click(); + + await page.getByTitle("Options").click(); + await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible(); + await page.getByLabel("Daily paid break").fill("35"); + await page.getByLabel("Workday length").fill("6"); + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByText("Work ends at 16:15:00")).toBeVisible(); + await expect( + page.getByText("Work duration: 1 hours, 45 minutes, 0 seconds"), + ).toBeVisible(); +}); + +async function saveUnchangedOptionsForm(page: Page) { + await page.getByTitle("Options").click(); + await expect(page.getByLabel("Daily paid break")).toBeVisible(); + await expect(page.getByLabel("Workday length")).toBeVisible(); + await page.getByRole("button", { name: "Save" }).click(); +} From f753707c0017502447b6d2dd6cc4d0ca242a4d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danko=20Lu=C4=8Di=C4=87?= Date: Sun, 19 Oct 2025 18:57:25 +0200 Subject: [PATCH 6/7] Style improvements: - modal look for options form - make time worked info clearer - slightly better look for work end estimate --- src/lib/leftPadNumber.test.ts | 14 ++++++ src/lib/leftPadNumber.ts | 3 ++ src/ui/Header.svelte | 3 +- src/ui/Input.svelte | 14 ++++++ src/ui/OptionsForm.svelte | 39 ++++++++++++++--- src/ui/Status.svelte | 2 +- src/ui/UserForm.svelte | 15 +++---- src/ui/WorkDuration.svelte | 17 ++++++-- src/ui/WorkdayEndEstimate.svelte | 16 +++++++ src/ui/WorkdayForm.svelte | 16 +++++-- tests/e2e.spec.ts | 73 ++++++++++++-------------------- 11 files changed, 144 insertions(+), 68 deletions(-) create mode 100644 src/lib/leftPadNumber.test.ts create mode 100644 src/lib/leftPadNumber.ts create mode 100644 src/ui/Input.svelte create mode 100644 src/ui/WorkdayEndEstimate.svelte diff --git a/src/lib/leftPadNumber.test.ts b/src/lib/leftPadNumber.test.ts new file mode 100644 index 0000000..5ad483c --- /dev/null +++ b/src/lib/leftPadNumber.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "vitest"; +import { leftPadNumber } from "./leftPadNumber"; + +test("should do nothing for multiple-digit numbers", () => { + expect.soft(leftPadNumber(10)).toStrictEqual("10"); + expect.soft(leftPadNumber(99)).toStrictEqual("99"); + expect.soft(leftPadNumber(123)).toStrictEqual("123"); +}); + +test("should add a zero for singe-digit numbers", () => { + expect.soft(leftPadNumber(1)).toStrictEqual("01"); + expect.soft(leftPadNumber(5)).toStrictEqual("05"); + expect.soft(leftPadNumber(9)).toStrictEqual("09"); +}); diff --git a/src/lib/leftPadNumber.ts b/src/lib/leftPadNumber.ts new file mode 100644 index 0000000..184bfd6 --- /dev/null +++ b/src/lib/leftPadNumber.ts @@ -0,0 +1,3 @@ +export function leftPadNumber(number: number): string { + return number < 10 ? `0${number}` : number.toString(); +} diff --git a/src/ui/Header.svelte b/src/ui/Header.svelte index 6c6ea9c..57dce98 100644 --- a/src/ui/Header.svelte +++ b/src/ui/Header.svelte @@ -30,7 +30,7 @@ color: white; margin-bottom: 1.5rem; align-items: center; - padding: 1.4rem; + padding: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem; background: linear-gradient( @@ -38,6 +38,7 @@ var(--color-gray-400) 0%, var(--color-gray-900) 100% ); + height: 4.5rem; } h1 { diff --git a/src/ui/Input.svelte b/src/ui/Input.svelte new file mode 100644 index 0000000..bd0373a --- /dev/null +++ b/src/ui/Input.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/ui/OptionsForm.svelte b/src/ui/OptionsForm.svelte index 2b8ac10..c953ada 100644 --- a/src/ui/OptionsForm.svelte +++ b/src/ui/OptionsForm.svelte @@ -1,5 +1,7 @@
- Work duration: {timeWorked.hours} hours, {timeWorked.minutes} minutes, {timeWorked.seconds} - seconds + {leftPadNumber(timeWorked.hours)} + hr + {leftPadNumber(timeWorked.minutes)} + min + {leftPadNumber(timeWorked.seconds)} + sec
diff --git a/src/ui/WorkdayEndEstimate.svelte b/src/ui/WorkdayEndEstimate.svelte new file mode 100644 index 0000000..78bd954 --- /dev/null +++ b/src/ui/WorkdayEndEstimate.svelte @@ -0,0 +1,16 @@ + + +
+ Estimated work end: {formatDate(estimate, "HH:mm:ss")} +
+ + diff --git a/src/ui/WorkdayForm.svelte b/src/ui/WorkdayForm.svelte index 661ff0b..5802000 100644 --- a/src/ui/WorkdayForm.svelte +++ b/src/ui/WorkdayForm.svelte @@ -4,9 +4,9 @@ import ConfirmationModal from "./ConfirmationModal.svelte"; import Favicon from "./Favicon.svelte"; import Status from "./Status.svelte"; + import WorkdayEndEstimate from "./WorkdayEndEstimate.svelte"; import WorkdayEvents from "./WorkdayEvents.svelte"; import WorkDuration from "./WorkDuration.svelte"; - import { formatDate } from "date-fns"; const { user: userProp, onChange } = $props(); let user: User = $state(userProp); @@ -45,9 +45,9 @@ {#if tracker.hasWorkdayStarted()} - -
- Work ends at {formatDate(tracker.calculateWorkEndTime(), "HH:mm:ss")} (estimate) +
+ +
{/if} @@ -119,4 +119,12 @@ gap: 0.8rem; justify-content: space-between; } + + .basicWorkInfo { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + margin-bottom: 1rem; + } diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index daab5b9..04a7752 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -1,6 +1,6 @@ import { test, expect, Page } from "@playwright/test"; -const delay = (milliseconds) => +const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); test("first visit, full workday", async ({ page }) => { @@ -273,35 +273,30 @@ test("Display hours worked so far", async ({ page }) => { await page.getByLabel("Daily paid break").fill("30"); await page.getByText("Start tracking").click(); - await expect(page.getByText(/Work duration/)).not.toBeVisible(); + await expect(page.getByText(/\d+ hr \d+ min \d+ sec/)).not.toBeVisible(); await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); await page.getByRole("button", { name: "Start Work" }).click(); - await expect( - page.getByText("Work duration: 0 hours, 0 minutes, 0 seconds"), - ).toBeVisible(); + await expect(page.getByText("00 hr 00 min 00 sec")).toBeVisible(); await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 5, 0)); await page.reload(); - await expect( - page.getByText("Work duration: 1 hours, 0 minutes, 0 seconds"), - ).toBeVisible(); + await expect(page.getByText("01 hr 00 min 00 sec")).toBeVisible(); await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 15, 25)); await page.reload(); - await expect( - page.getByText("Work duration: 1 hours, 10 minutes, 25 seconds"), - ).toBeVisible(); + await expect(page.getByText("01 hr 10 min 25 sec")).toBeVisible(); await page.clock.setFixedTime(new Date(2025, 2, 2, 16, 10, 30)); + await expect(page.getByText(/\d+ hr \d+ min \d+ sec/)).toBeVisible(); await page.getByRole("button", { name: "End Work" }).click(); await page.getByRole("button", { name: "Yes, I'm done for today" }).click(); // Test fails without this delay in headless chromium browser for playwright // versions 1.52.0 or higher. await delay(10); await page.reload(); - await expect(page.getByText(/Work duration/)).not.toBeVisible(); + await expect(page.getByText(/\d+ hr \d+ min \d+ sec/)).not.toBeVisible(); }); test("can change paid break duration through the options menu after workday started", async ({ @@ -318,15 +313,13 @@ test("can change paid break duration through the options menu after workday star await page.getByRole("button", { name: "Start break" }).click(); await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 55, 0)); await page.getByRole("button", { name: "End break" }).click(); - await expect( - page.getByText("Work duration: 1 hours, 45 minutes, 0 seconds"), - ).toBeVisible(); + await expect(page.getByText("01 hr 45 min 00 sec")).toBeVisible(); await expect(page.getByLabel("Daily paid break")).not.toBeVisible(); await page.getByTitle("Options").click(); await expect(page.getByLabel("Daily paid break")).toBeVisible(); await expect( - page.getByRole("textbox", { name: "Daily paid break" }), + page.getByRole("spinbutton", { name: "Daily paid break" }), ).toHaveValue("45"); // default await page.getByLabel("Daily paid break").fill("35"); await page.getByRole("button", { name: "Save" }).click(); @@ -334,15 +327,13 @@ test("can change paid break duration through the options menu after workday star // Form is closed. await expect(page.getByLabel("Daily paid break")).not.toBeVisible(); // Smaller paid break should reduce hours worked. - await expect( - page.getByText("Work duration: 1 hours, 35 minutes, 0 seconds"), - ).toBeVisible(); + await expect(page.getByText("01 hr 35 min 00 sec")).toBeVisible(); // Test fails without this delay in headless chromium browser for playwright // versions 1.52.0 or higher. await delay(10); await page.reload(); await expect( - page.getByText("Work duration: 1 hours, 35 minutes, 0 seconds"), + page.getByText("01 hr 35 min 00 sec"), "Changes should be saved in the actual database", ).toBeVisible(); }); @@ -358,7 +349,7 @@ test("can change paid break duration through the options menu before workday sta await page.getByTitle("Options").click(); await expect(page.getByLabel("Daily paid break")).toBeVisible(); await expect( - page.getByRole("textbox", { name: "Daily paid break" }), + page.getByRole("spinbutton", { name: "Daily paid break" }), ).toHaveValue("45"); // default await page.getByLabel("Daily paid break").fill("35"); await page.getByRole("button", { name: "Save" }).click(); @@ -373,15 +364,13 @@ test("can change paid break duration through the options menu before workday sta await page.clock.setFixedTime(new Date(2025, 2, 2, 9, 55, 0)); await page.getByRole("button", { name: "End break" }).click(); // Smaller paid break should reduce hours worked. - await expect( - page.getByText("Work duration: 1 hours, 35 minutes, 0 seconds"), - ).toBeVisible(); + await expect(page.getByText("01 hr 35 min 00 sec")).toBeVisible(); // Test fails without this delay in headless chromium browser for playwright // versions 1.52.0 or higher. await delay(10); await page.reload(); await expect( - page.getByText("Work duration: 1 hours, 35 minutes, 0 seconds"), + page.getByText("01 hr 35 min 00 sec"), "Changes should be saved in the actual database", ).toBeVisible(); }); @@ -391,12 +380,12 @@ test("can see the current estimate of when the workday will end", async ({ }) => { await page.goto("/"); await page.getByLabel("Username").fill("Mark S"); - await expect(page.getByText(/Work ends at/)).not.toBeVisible(); + await expect(page.getByText(/Estimated work end:/)).not.toBeVisible(); await page.getByText("Start tracking").click(); await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); - await expect(page.getByText(/Work ends at/)).not.toBeVisible(); + await expect(page.getByText(/Estimated work end:/)).not.toBeVisible(); await page.getByRole("button", { name: "Start work" }).click(); - await expect(page.getByText("Work ends at 16:05:00")).toBeVisible(); + await expect(page.getByText("Estimated work end: 16:05:00")).toBeVisible(); }); test("can change workday length through the options menu before workday starts", async ({ @@ -404,21 +393,21 @@ test("can change workday length through the options menu before workday starts", }) => { await page.goto("/"); await page.getByLabel("Username").fill("Mark S"); - await expect(page.getByText(/Work ends at/)).not.toBeVisible(); + await expect(page.getByText(/Estimated work end:/)).not.toBeVisible(); await page.getByText("Start tracking").click(); await page.clock.setFixedTime(new Date(2025, 2, 2, 8, 5, 0)); - await expect(page.getByText(/Work ends at/)).not.toBeVisible(); + await expect(page.getByText(/Estimated work end:/)).not.toBeVisible(); await page.getByTitle("Options").click(); await expect(page.getByLabel("Workday length")).toBeVisible(); await expect( - page.getByRole("textbox", { name: "Workday length" }), + page.getByRole("spinbutton", { name: "Workday length" }), ).toHaveValue("8"); // default await page.getByLabel("Workday length").fill("6"); await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Start work" }).click(); - await expect(page.getByText("Work ends at 14:05:00")).toBeVisible(); + await expect(page.getByText("Estimated work end: 14:05:00")).toBeVisible(); }); test("can change workday length through the options menu after workday starts", async ({ @@ -435,7 +424,7 @@ test("can change workday length through the options menu after workday starts", await page.getByLabel("Workday length").fill("5"); await page.getByRole("button", { name: "Save" }).click(); - await expect(page.getByText("Work ends at 13:05:00")).toBeVisible(); + await expect(page.getByText("Estimated work end: 13:05:00")).toBeVisible(); }); test("submit Options form without changing values", async ({ page }) => { @@ -452,17 +441,13 @@ test("submit Options form without changing values", async ({ page }) => { await page.clock.setFixedTime(new Date(2025, 2, 2, 10, 0, 0)); await page.getByRole("button", { name: "End break" }).click(); - await expect(page.getByText("Work ends at 16:15:00")).toBeVisible(); - await expect( - page.getByText("Work duration: 1 hours, 45 minutes, 0 seconds"), - ).toBeVisible(); + await expect(page.getByText("Estimated work end: 16:15:00")).toBeVisible(); + await expect(page.getByText("01 hr 45 min 00 sec")).toBeVisible(); await saveUnchangedOptionsForm(page); - await expect(page.getByText("Work ends at 16:15:00")).toBeVisible(); - await expect( - page.getByText("Work duration: 1 hours, 45 minutes, 0 seconds"), - ).toBeVisible(); + await expect(page.getByText("Estimated work end: 16:15:00")).toBeVisible(); + await expect(page.getByText("01 hr 45 min 00 sec")).toBeVisible(); }); test("cancel Options form should close it without saving changes", async ({ @@ -484,10 +469,8 @@ test("cancel Options form should close it without saving changes", async ({ await page.getByLabel("Workday length").fill("6"); await page.getByRole("button", { name: "Cancel" }).click(); - await expect(page.getByText("Work ends at 16:15:00")).toBeVisible(); - await expect( - page.getByText("Work duration: 1 hours, 45 minutes, 0 seconds"), - ).toBeVisible(); + await expect(page.getByText("Estimated work end: 16:15:00")).toBeVisible(); + await expect(page.getByText("01 hr 45 min 00 sec")).toBeVisible(); }); async function saveUnchangedOptionsForm(page: Page) { From 2b768530a44e358053bdfcc6f032465752017381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danko=20Lu=C4=8Di=C4=87?= Date: Mon, 3 Nov 2025 21:30:04 +0100 Subject: [PATCH 7/7] Migrate old data to version 2 of indexedDb database - switch to idb package for ease of use --- package-lock.json | 7 ++ package.json | 1 + src/lib/database.test.ts | 117 +++++++++++++++++-- src/lib/database.ts | 235 ++++++++++++++++++++++----------------- 4 files changed, 243 insertions(+), 117 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26fa4fd..8ce95a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "GPL-3.0", "dependencies": { "date-fns": "^4.1.0", + "idb": "^8.0.3", "uuid": "^11.1.0" }, "devDependencies": { @@ -2536,6 +2537,12 @@ "node": ">=8" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index e613524..8800d64 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "date-fns": "^4.1.0", + "idb": "^8.0.3", "uuid": "^11.1.0" }, "optionalDependencies": { diff --git a/src/lib/database.test.ts b/src/lib/database.test.ts index 635c74b..4965335 100644 --- a/src/lib/database.test.ts +++ b/src/lib/database.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; -import { getDatabase, type NewUserData } from "./database"; +import { getDatabase, type NewUserSettings } from "./database"; import { v4 as uuidv4 } from "uuid"; -import type { User } from "./tracker"; +import { type User, type Workday } from "./tracker"; /** * Vitest browser remembers already created databases on repeated runs, so we @@ -9,13 +9,25 @@ import type { User } from "./tracker"; * * Created databases are NOT remembered if we re-run the test script again. */ -async function getFreshDatabase() { - return await getDatabase(`testDB-${uuidv4()}`); +async function getFreshDatabase(version = 2) { + return await getDatabase(`testDB-${uuidv4()}`, version); +} + +async function getExistingDatabase(name: string, version = 2) { + return await getDatabase(name, version); } test("store user data in database", async () => { - const user1: NewUserData = { username: "Dylan G.", paidBreakDuration: 45 }; - const user2: NewUserData = { username: "Irving B.", paidBreakDuration: 50 }; + const user1: NewUserSettings = { + username: "Dylan G.", + paidBreakDuration: 45, + workdayLength: 8, + }; + const user2: NewUserSettings = { + username: "Irving B.", + paidBreakDuration: 50, + workdayLength: 6, + }; const db = await getFreshDatabase(); const firstUserInsertResult = await db.insertUser(user1); @@ -30,8 +42,16 @@ test("store user data in database", async () => { }); test("storing two users with the same username should fail", async () => { - const user1: NewUserData = { username: "Helly R.", paidBreakDuration: 45 }; - const user2: NewUserData = { username: "Helly R.", paidBreakDuration: 50 }; + const user1: NewUserSettings = { + username: "Helly R.", + paidBreakDuration: 45, + workdayLength: 8, + }; + const user2: NewUserSettings = { + username: "Helly R.", + paidBreakDuration: 50, + workdayLength: 8.5, + }; const db = await getFreshDatabase(); @@ -40,8 +60,16 @@ test("storing two users with the same username should fail", async () => { }); test("getAllUsers should return all users in the database", async () => { - const user1: NewUserData = { username: "Mark S.", paidBreakDuration: 45 }; - const user2: NewUserData = { username: "Helly R.", paidBreakDuration: 50 }; + const user1: NewUserSettings = { + username: "Mark S.", + paidBreakDuration: 45, + workdayLength: 6, + }; + const user2: NewUserSettings = { + username: "Helly R.", + paidBreakDuration: 50, + workdayLength: 6.5, + }; const db = await getFreshDatabase(); @@ -55,6 +83,8 @@ test("getAllUsers should return all users in the database", async () => { expect(twoUsers[1].id).not.toBeFalsy(); expect(twoUsers[0].settings.paidBreakDuration).toEqual(45); expect(twoUsers[1].settings.paidBreakDuration).toEqual(50); + expect(twoUsers[0].settings.workdayLength).toEqual(6); + expect(twoUsers[1].settings.workdayLength).toEqual(6.5); expect(twoUsers[0].settings.username).toEqual("Mark S."); expect(twoUsers[1].settings.username).toEqual("Helly R."); expect(twoUsers[0].trackingData).toEqual({ workdays: [] }); @@ -70,9 +100,10 @@ test("getUserById should return null if no user found for given id", async () => }); test("getUserById should return a user", async () => { - const userToInsert: NewUserData = { + const userToInsert: NewUserSettings = { username: "Mark S.", paidBreakDuration: 45, + workdayLength: 7, }; const db = await getFreshDatabase(); const userId = await db.insertUser(userToInsert); @@ -83,14 +114,16 @@ test("getUserById should return a user", async () => { expect(user?.id).toHaveLength(36); expect(user?.settings.username).toEqual("Mark S."); expect(user?.settings.paidBreakDuration).toEqual(45); + expect(user?.settings.workdayLength).toEqual(7); expect(user?.trackingData).toEqual({ workdays: [] }); }); test("update existing user in database", async () => { const db = await getFreshDatabase(); - const userToInsert: NewUserData = { + const userToInsert: NewUserSettings = { username: "Gemma S.", paidBreakDuration: 30, + workdayLength: 8, }; const userId = await db.insertUser(userToInsert); const user = (await db.getUserById(userId)) as User; @@ -101,6 +134,7 @@ test("update existing user in database", async () => { { type: "start-break", time: new Date(2025, 2, 2, 11, 15, 0) }, ], paidBreakDuration: 30, + workdayLength: 8, }, ]; @@ -110,6 +144,65 @@ test("update existing user in database", async () => { expect(updatedUser).toEqual(user); }); +test("upgrading database to version 2", async () => { + // @ts-expect-error Insert user with version 1 of the data (no paidBreakDuration and workdayLength info) + // into the database. + const userToInsert: NewUserSettings = { + username: "Mark S.", + }; + const oldDatabase = await getFreshDatabase(1); + const userId = await oldDatabase.insertUser(userToInsert); + const oldUser = (await oldDatabase.getUserById(userId)) as User; + oldUser.trackingData = { + workdays: [ + { + events: [ + { + time: new Date("2025-11-03T20:05:53.626Z"), + type: "start-workday", + }, + { + time: new Date("2025-11-03T21:05:53.626Z"), + type: "end-workday", + }, + ], + } as Workday, + // Specifically no paidBreakDuration and workdayLength. + ], + }; + oldDatabase.updateUser(oldUser); + oldDatabase.close(); + // Open the database with version 2. + const newDatabase = await getExistingDatabase(oldDatabase.name, 2); + + const user = await newDatabase.getUserById(userId); + + // Old data should be automatically migrated. + expect(typeof user?.id).toEqual("string"); + expect(user?.id).toHaveLength(36); + expect(user?.settings.username).toEqual("Mark S."); + expect(user?.settings.paidBreakDuration).toEqual(45); + expect(user?.settings.workdayLength).toEqual(8); + expect(user?.trackingData).toEqual({ + workdays: [ + { + events: [ + { + time: new Date("2025-11-03T20:05:53.626Z"), + type: "start-workday", + }, + { + time: new Date("2025-11-03T21:05:53.626Z"), + type: "end-workday", + }, + ], + paidBreakDuration: 45, + workdayLength: 8, + }, + ], + }); +}); + function byBreakDuration(a: User, b: User) { return a.settings.paidBreakDuration - b.settings.paidBreakDuration; } diff --git a/src/lib/database.ts b/src/lib/database.ts index 4bc9a7d..7ff8711 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -1,120 +1,145 @@ import { v4 as uuidv4 } from "uuid"; +import { openDB, unwrap } from "idb"; import type { Settings, User } from "./tracker"; /** * Used to access local database data. */ interface Database { - insertUser(user: NewUserData): Promise; + name: string; + insertUser(user: NewUserSettings): Promise; updateUser(user: User): Promise; getUserById(userId: string | false): Promise; getAllUsers(): Promise; getUserCount(): Promise; + close(): void; } -export type NewUserData = Settings; - -export function getDatabase(name = "work-hours-tracker-db"): Promise { - let db: IDBDatabase; - - return new Promise((resolve, reject) => { - const request = window.indexedDB.open(name, 1); - - request.onerror = (event) => { - console.error("Error while opening database", event); - reject(event); - }; - - request.onsuccess = () => { - db = request.result; - db.addEventListener("error", (event) => { - console.error("Database error occurred", event); - }); - - resolve({ - async insertUser(userSettings) { - const transaction = db.transaction(["users"], "readwrite"); - const usersTable = transaction.objectStore("users"); - const userId = uuidv4(); - usersTable.add({ - id: userId, - settings: userSettings, - trackingData: { workdays: [] }, - }); - - return new Promise((resolve) => { - transaction.addEventListener("complete", () => { - resolve(userId); - }); - transaction.addEventListener("error", () => { - resolve(false); - }); - }); - }, - async updateUser(user) { - const transaction = db.transaction(["users"], "readwrite"); - const usersTable = transaction.objectStore("users"); - - usersTable.put(user); - - return new Promise((resolve) => { - transaction.addEventListener("complete", () => { - resolve(true); - }); - transaction.addEventListener("error", () => { - resolve(false); - }); - }); - }, - async getUserById(userId: string | false): Promise { - return new Promise((resolve) => { - const userRequest = db - .transaction(["users"], "readonly") - .objectStore("users") - .get(userId || ""); - - userRequest?.addEventListener("success", () => { - resolve(userRequest.result || null); - }); - }); - - return null; - }, - async getAllUsers() { - const usersRequest = db - .transaction(["users"], "readonly") - .objectStore("users") - .getAll(); - - return new Promise((resolve) => { - usersRequest.addEventListener("success", () => { - resolve(usersRequest.result); - }); - }); - }, - async getUserCount() { - const usersCountRequest = db - .transaction(["users"], "readonly") - .objectStore("users") - .count(); - - return new Promise((resolve) => { - usersCountRequest.addEventListener("success", () => { - resolve(usersCountRequest.result); - }); - }); - }, - }); - }; - - request.addEventListener("upgradeneeded", (init) => { - const db = (init.target as IDBOpenDBRequest).result as IDBDatabase; - const workdaysTable = db.createObjectStore("workdays", { keyPath: "id" }); - workdaysTable.createIndex("date", "date", { unique: true }); - const usersTable = db.createObjectStore("users", { keyPath: "id" }); - usersTable.createIndex("settings.username", "settings.username", { - unique: true, - }); - }); +export type NewUserSettings = Settings; + +/** + * Catches extra error that is thrown if inserting an object with same unique property. + * + * @see https://github.com/jakearchibald/idb/issues/256#issuecomment-1048551626 + */ +function preventTransactionCloseOnError(promise: Promise) { + const request = unwrap(promise); + request.addEventListener("error", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + + return promise; +} + +export async function getDatabase( + name = "work-hours-tracker-db", + version = 2, +): Promise { + const indexedDatabase = await openDB(name, version, { + async upgrade(db, oldVersion, newVersion, transaction) { + /** + * Recommended pattern for version upgrades. + * + * @see https://stackoverflow.com/a/44007456 + */ + if (oldVersion < 1) { + // Create initial schema. + const workdaysTable = db.createObjectStore("workdays", { + keyPath: "id", + }); + workdaysTable.createIndex("date", "date", { unique: true }); + const usersTable = db.createObjectStore("users", { keyPath: "id" }); + usersTable.createIndex("settings.username", "settings.username", { + unique: true, + }); + } + + if (oldVersion < 2) { + // Migrate data to v2: add workdayLength and paidBreakDuration to user settings and workdays. + const usersRequest = transaction.objectStore("users"); + const users = await usersRequest.getAll(); + users.forEach(async (user: User) => { + user.settings.paidBreakDuration = 45; + user.settings.workdayLength = 8; + + user.trackingData.workdays = user.trackingData.workdays.map( + (workday) => { + return { ...workday, paidBreakDuration: 45, workdayLength: 8 }; + }, + ); + + await usersRequest.put(user); + }); + } + }, + blocked() { + console.error( + "Database creation blocked! Please close all other tabs with this site open!", + ); + }, + terminated() { + console.warn("Database closed"); + }, }); + + const workHoursDb: Database = { + name: indexedDatabase.name, + async insertUser(userSettings) { + const transaction = indexedDatabase.transaction(["users"], "readwrite"); + const usersTable = transaction.objectStore("users"); + const userId = uuidv4(); + + let result; + try { + [, result] = await Promise.all([ + transaction.done, + preventTransactionCloseOnError( + usersTable.add({ + id: userId, + settings: userSettings, + trackingData: { workdays: [] }, + }), + ), + ]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return false; + } + + return result ? userId : false; + }, + async updateUser(user) { + const transaction = indexedDatabase.transaction(["users"], "readwrite"); + const usersTable = transaction.objectStore("users"); + + return Boolean(await usersTable.put(user)); + }, + async getUserById(userId: string | false): Promise { + const userRequest = indexedDatabase + .transaction(["users"], "readonly") + .objectStore("users"); + + return (await userRequest.get(userId || "")) || null; + }, + async getAllUsers() { + const usersRequest = indexedDatabase + .transaction(["users"], "readonly") + .objectStore("users"); + + return await usersRequest.getAll(); + }, + async getUserCount() { + const usersCountRequest = indexedDatabase + .transaction(["users"], "readonly") + .objectStore("users"); + + return await usersCountRequest.count(); + }, + close() { + indexedDatabase.close(); + }, + }; + + return workHoursDb; }