Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ The frontend lives in the `src` directory and is built with React 18, TypeScript
- [`src/pages`](../src/pages) – route components; each page represents a top-level feature like tasks, notes or the pomodoro timer.
- [`src/hooks`](../src/hooks) – custom React hooks for shared logic.
- [`src/providers`](../src/providers) – context providers for state like themes or query clients.
- [`src/stores`](../src/stores) – Zustand stores for global state.
- [`src/locales`](../src/locales) – translation files in German and English consumed by `react-i18next`.
- [`src/utils`](../src/utils) and [`src/lib`](../src/lib) – helper functions and abstractions.
- [`src/shared`](../src/shared) – Zustand stores and shared types.
- [`src/shared`](../src/shared) – shared utilities and types.

## Routing

Expand All @@ -25,9 +26,9 @@ defined in [`src/components/Navbar.tsx`](../src/components/Navbar.tsx).

## State Management

Local state is handled with Zustand stores and React hooks. For example,
[`useTimers`](../src/hooks/useTimers.tsx) persists timer state to local storage
and syncs it to the server.
Local state is handled with Zustand stores. For example,
the [timers store](../src/stores/timers.ts) persists timer state to local storage
and syncs it to the server via the [TimersProvider](../src/providers/TimersProvider.tsx).
Server state and caching are provided by `@tanstack/react-query` via context
providers in [`src/providers`](../src/providers).

Expand Down
11 changes: 11 additions & 0 deletions docs/state-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# State Management

This project evaluated different libraries for global state handling. Redux Toolkit offers powerful tooling but introduces boilerplate. Zustand provides a minimal API, shallow learning curve and works well with React's hooks.

We standardize on **Zustand** for client-side state:

- Stores live in [`src/stores`](../src/stores).
- Context providers that persist or sync data reside in [`src/providers`](../src/providers).
- Components and hooks import stores directly, e.g. `import { useTimers } from "@/stores/timers"`.

Existing local stores were migrated into this structure. New global state should follow the same pattern.
2 changes: 1 addition & 1 deletion src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Navbar from "./Navbar";
import { usePomodoroStore } from "./PomodoroTimer";
import { usePomodoroStore } from "@/stores/pomodoro";

interface SortableCategoryProps {
category: Category;
Expand Down Expand Up @@ -163,7 +163,7 @@
params.set("sort", sortCriteria);
setSearchParams(params, { replace: true });
}
}, [sortCriteria]);

Check warning on line 166 in src/components/Dashboard.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has missing dependencies: 'searchParams' and 'setSearchParams'. Either include them or remove the dependency array

useEffect(() => {
setTaskLayout(defaultTaskLayout);
Expand All @@ -187,7 +187,7 @@
setCurrentCategoryId(null);
setViewMode("categories");
}
}, [searchParams, categories]);

Check warning on line 190 in src/components/Dashboard.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has missing dependencies: 'selectedCategory' and 'setCurrentCategoryId'. Either include them or remove the dependency array

// Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
Expand Down Expand Up @@ -216,7 +216,7 @@
const colors = new Set<number>();
tasksForColors.forEach((task) => colors.add(task.color));
return Array.from(colors);
}, [selectedCategory, tasks]);

Check warning on line 219 in src/components/Dashboard.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useMemo has missing dependencies: 'getTasksByCategory' and 'showHidden'. Either include them or remove the dependency array

const categoryColorOptions = useMemo(() => {
const colors = new Set<number>();
Expand Down Expand Up @@ -257,7 +257,7 @@
return cats;
}, [filteredCategories, sortCriteria]);

const filteredTasks = selectedCategory

Check warning on line 260 in src/components/Dashboard.tsx

View workflow job for this annotation

GitHub Actions / test

The 'filteredTasks' conditional could make the dependencies of useMemo Hook (at line 318) change on every render. To fix this, wrap the initialization of 'filteredTasks' in its own useMemo() Hook
? (showHidden
? tasks.filter(
(t) => t.categoryId === selectedCategory.id && !t.parentId,
Expand Down
2 changes: 1 addition & 1 deletion src/components/PomodoroTicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { usePomodoroStore } from "./PomodoroTimer";
import { usePomodoroStore } from "@/stores/pomodoro";
import { usePomodoroHistory } from "@/hooks/usePomodoroHistory.tsx";

const PomodoroTicker = () => {
Expand All @@ -25,7 +25,7 @@
setLastTick(Date.now());
}, 1000);
return () => clearInterval(interval);
}, [tick, setLastTick]);

Check warning on line 28 in src/components/PomodoroTicker.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has a missing dependency: 'lastTick'. Either include it or remove the dependency array

useEffect(() => {
if (prevMode.current === "work" && mode === "break" && startTime) {
Expand Down
115 changes: 1 addition & 114 deletions src/components/PomodoroTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import ReactDOM from "react-dom/client";
import { Button } from "@/components/ui/button";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { usePomodoroStore } from "@/stores/pomodoro";
import { useSettings, SettingsProvider } from "@/hooks/useSettings";
import { hslToHex, hexToHsl, complementaryColor } from "@/utils/color";
import { playSound } from "@/utils/sounds";
Expand All @@ -12,118 +11,6 @@ import {
PomodoroHistoryProvider,
} from "@/hooks/usePomodoroHistory.tsx";

interface PomodoroState {
isRunning: boolean;
isPaused: boolean;
remainingTime: number;
mode: "work" | "break";
currentTaskId?: string;
workDuration: number;
breakDuration: number;
startTime?: number;
lastTick?: number;
pauseStart?: number;
start: (taskId?: string) => void;
pause: () => void;
resume: () => void;
reset: () => void;
startBreak: () => void;
skipBreak: () => void;
tick: () => void;
setStartTime: (time?: number) => void;
setLastTick: (time: number) => void;
setDurations: (work: number, brk: number) => void;
}

const WORK_DURATION = 25 * 60; // 25 Minuten
const BREAK_DURATION = 5 * 60; // 5 Minuten

export const usePomodoroStore = create<PomodoroState>()(
persist(
(set) => ({
isRunning: false,
isPaused: false,
pauseStart: undefined,
remainingTime: WORK_DURATION,
mode: "work",
currentTaskId: undefined,
workDuration: WORK_DURATION,
breakDuration: BREAK_DURATION,
startTime: undefined,
lastTick: undefined,
start: (taskId?: string) =>
set((state) => ({
isRunning: true,
isPaused: false,
pauseStart: undefined,
remainingTime: state.workDuration,
mode: "work",
currentTaskId: taskId,
startTime: Date.now(),
lastTick: Date.now(),
})),
pause: () => set({ isPaused: true, pauseStart: Date.now() }),
resume: () =>
set({ isPaused: false, lastTick: Date.now(), pauseStart: undefined }),
reset: () =>
set((state) => ({
isRunning: false,
isPaused: false,
remainingTime: state.workDuration,
mode: "work",
currentTaskId: undefined,
startTime: undefined,
lastTick: undefined,
pauseStart: undefined,
})),
startBreak: () =>
set((state) => ({
isRunning: true,
isPaused: false,
pauseStart: undefined,
mode: "break",
remainingTime: state.breakDuration,
lastTick: Date.now(),
})),
skipBreak: () =>
set((state) => ({
isRunning: true,
isPaused: false,
pauseStart: undefined,
mode: "work",
remainingTime: state.workDuration,
lastTick: Date.now(),
})),
tick: () =>
set((state) => {
if (!state.isRunning || state.isPaused) return state;
if (state.remainingTime > 0) {
return {
remainingTime: state.remainingTime - 1,
lastTick: Date.now(),
};
}
const nextMode = state.mode === "work" ? "break" : "work";
return {
mode: nextMode,
remainingTime:
nextMode === "work" ? state.workDuration : state.breakDuration,
lastTick: Date.now(),
} as PomodoroState;
}),
setStartTime: (time) => set({ startTime: time }),
setLastTick: (time) => set({ lastTick: time }),
setDurations: (work, brk) =>
set((state) => ({
workDuration: work,
breakDuration: brk,
remainingTime: !state.isRunning ? work : state.remainingTime,
})),
}),
{ name: "pomodoro" },
),
);

const formatTime = (sec: number) => {
const m = Math.floor(sec / 60)
.toString()
Expand Down
2 changes: 1 addition & 1 deletion src/components/TimerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
Settings,
} from "lucide-react";
import TimerCircle from "./TimerCircle";
import { useTimers } from "@/hooks/useTimers.tsx";
import { useTimers } from "@/stores/timers";
import { useSettings } from "@/hooks/useSettings";
import { isColorDark, complementarySameHue, adjustColor } from "@/utils/color";
import { Button } from "@/components/ui/button";
Expand Down
2 changes: 1 addition & 1 deletion src/components/TimerTicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { useTimers } from "@/hooks/useTimers.tsx";
import { useTimers } from "@/stores/timers";

const TimerTicker = () => {
const tick = useTimers((state) => state.tick);
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import TaskCard from "@/components/TaskCard";
import Navbar from "@/components/Navbar";
import TaskModal from "@/components/TaskModal";
import { useNavigate } from "react-router-dom";
import { usePomodoroStore } from "@/components/PomodoroTimer";
import { usePomodoroStore } from "@/stores/pomodoro";
import { useToast } from "@/hooks/use-toast";
import { Task, TaskFormData } from "@/types";
import { flattenTasks, FlattenedTask } from "@/utils/taskUtils";
Expand Down
2 changes: 1 addition & 1 deletion src/pages/TimerDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import Navbar from "@/components/Navbar";
import TimerCircle from "@/components/TimerCircle";
import { Button } from "@/components/ui/button";
import { useTimers } from "@/hooks/useTimers.tsx";
import { useTimers } from "@/stores/timers";
import { Play, Pause, RotateCcw, Plus, Edit } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import { isColorDark, complementaryColor } from "@/utils/color";
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Timers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Navbar from "@/components/Navbar";
import TimerCard from "@/components/TimerCard";
import TimerModal from "@/components/TimerModal";
import { Button } from "@/components/ui/button";
import { useTimers } from "@/hooks/useTimers.tsx";
import { useTimers } from "@/stores/timers";
import { useTranslation } from "react-i18next";
import {
DndContext,
Expand Down
2 changes: 1 addition & 1 deletion src/providers/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { FlashcardStoreProvider } from "@/hooks/useFlashcardStore";
import { HabitStoreProvider } from "@/hooks/useHabitStore";
import { InventoryProvider } from "@/hooks/useInventoryStore";
import { PomodoroHistoryProvider } from "@/hooks/usePomodoroHistory";
import { TimersProvider } from "@/hooks/useTimers";
import { TimersProvider } from "@/providers/TimersProvider";
import { WorklogProvider } from "@/hooks/useWorklog";

interface AppProvidersProps {
Expand Down
65 changes: 65 additions & 0 deletions src/providers/TimersProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React, { useEffect, useState } from "react";
import { useTimers } from "@/stores/timers";
import {
loadOfflineData,
updateOfflineData,
syncWithServer,
} from "@/utils/offline";

export const TimersProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const setTimers = useTimers((s) => s.setTimers);
const timers = useTimers((s) => s.timers);
const [loaded, setLoaded] = useState(false);

useEffect(() => {
const load = async () => {
const offline = loadOfflineData();
if (offline) setTimers(offline.timers || []);
const synced = await syncWithServer();
setTimers(synced.timers || []);
setLoaded(true);
};
load();
}, [setTimers]);

useEffect(() => {
if (!loaded) return;

// Only sync for non-tick updates (debounced approach)
const hasRunningTimers = timers.some((t) => t.isRunning && !t.isPaused);

const save = async () => {
try {
updateOfflineData({ timers });
if (navigator.onLine) {
await fetch("/api/timers", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(timers),
});

// Only sync with server for significant changes, not tick updates
if (!hasRunningTimers) {
await syncWithServer();
}
}
} catch (err) {
console.error("Error saving timers", err);
}
};

// Debounce: Don't sync immediately if timers are running (frequent ticks)
if (hasRunningTimers) {
const timeoutId = setTimeout(save, 5000); // Sync every 5 seconds max when running
return () => clearTimeout(timeoutId);
} else {
save(); // Immediate sync for non-running states (start/stop/pause actions)
}
}, [timers, loaded]);

return <>{children}</>;
};

export { useTimers } from "@/stores/timers";
Loading
Loading