diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 18325d0..6fe2f83 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,4 +1,4 @@ -import { timestamp, pgTable, varchar, integer, unique, pgEnum } from "drizzle-orm/pg-core"; +import { timestamp, pgTable, varchar, integer, unique, pgEnum, serial, date } from "drizzle-orm/pg-core"; // export const statusEnum = pgEnum("status", ["pending", "completed"]); @@ -6,7 +6,7 @@ export const users = pgTable("users", { id: varchar("id", { length: 255 }).primaryKey(), email: varchar("email", { length: 255 }).notNull(), name: varchar("name", { length: 255 }), - createdAt: timestamp("created_at"), + createdAt: timestamp("created_at", { withTimezone: true }), }); export const categories = pgTable("categories", { @@ -22,7 +22,7 @@ export const tasksList = pgTable("tasks_lists", { userId: varchar("user_id").references(() => users.id, {onDelete: 'cascade'}).notNull(), title: varchar("title", { length: 255 }).notNull(), categoryId: integer("category_id").references(() => categories.id, {onDelete: 'set null'}), - createdAt: timestamp("created_at").defaultNow() + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow() }); export const statusEnum = pgEnum("status", ['incomplete', 'in_progress', 'completed']); @@ -33,4 +33,15 @@ export const tasks = pgTable("tasks", { title: varchar("title", { length: 255 }).notNull(), status: statusEnum(), order: integer("order").notNull().default(0) +}); + +export const productivityTimer = pgTable("productivity_timer", { + id: serial("id").primaryKey(), + userId: varchar("user_id").references(() => users.id, { onDelete: 'cascade'}).notNull(), + startedAt: timestamp("started_at", { withTimezone: true }).notNull(), + endedAt: timestamp("ended_at", { withTimezone: true }).notNull(), + duration: integer("duration"), + date: date("date").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow() }); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index a88ffe6..222f55c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,7 @@ import { swaggerUI, SwaggerUI } from '@hono/swagger-ui'; import { swaggerSpec } from './lib/swagger'; import analyticsRouter from './routes/analyticsRoutes'; +import pRouter from './routes/productivityRoutes'; const app = new Hono() @@ -55,6 +56,7 @@ app.route("/user", userRouter); app.route("/task", tasksRouter); app.route("/category", categoryRouter); app.route("/analytics", analyticsRouter); +app.route("/prod", pRouter); // pRouter -> productivityRouter (Didn't want to write productivityRouter over and over again) const ai = new GoogleGenAI({ apiKey: process.env.GOOGLE_GEMINI_API_KEY }); diff --git a/backend/src/routes/productivityRoutes.ts b/backend/src/routes/productivityRoutes.ts new file mode 100644 index 0000000..5635f5f --- /dev/null +++ b/backend/src/routes/productivityRoutes.ts @@ -0,0 +1,134 @@ +import { Hono } from "hono"; +import { requireAuth } from "../middleware/requireAuth"; +import { productivityTimer } from "../db/schema"; +import { db } from "../db/db"; +import { eq, and } from "drizzle-orm"; + +const pRouter = new Hono(); + +type AddBodyType = { + // checked + startTime: number; + endTime: number; + timeZone?: string; +}; + +function isoDateInTimeZone(d: Date, timeZone: string | undefined) { + const dtf = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + return dtf.format(d); +} + +pRouter.post("/add", requireAuth, async (c) => { + try { + const { userId } = c.get("authData"); + const { startTime, endTime, timeZone }: AddBodyType = await c.req.json(); + + if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) { + return c.json({ message: "Start and end must be finite!" }, 400); + } + + const durationSec = (endTime - startTime) / 1000; + + const startDate = new Date(startTime); + const endDate = new Date(endTime); + + const startDateOnly = isoDateInTimeZone(startDate, timeZone); + const endDateOnly = isoDateInTimeZone(endDate, timeZone); + + let dateOnly: string | undefined; + + if(Number.parseInt(startDateOnly.substring(8)) == Number.parseInt(endDateOnly.substring(8))) { + dateOnly = startDateOnly; + } else if (Number.parseInt(startDateOnly.substring(8)) < Number.parseInt(endDateOnly.substring(8))) { + dateOnly = endDateOnly; + } else { + return c.json({ success: false, message: "End date cannot be smaller than start date" }, 400); + } + + // checking for existing data + const dates = await db + .select() + .from(productivityTimer) + .where( + and( + eq(productivityTimer.date, dateOnly), + eq(productivityTimer.startedAt, startDate), + eq(productivityTimer.endedAt, endDate), + eq(productivityTimer.userId, userId) + ) + ); + console.log("Existing Dates =", dates); + + if (dates.length == 0) { + // If there is no existing entry + const res = await db.insert(productivityTimer).values({ + userId: userId, + startedAt: startDate, + endedAt: endDate, + duration: Math.floor(durationSec), + date: dateOnly, + }); + + console.log("Result = " + res); + + return c.json( + { success: true, message: "Time has been added", res }, + 200 + ); + } else { + console.error("The given time already exists!"); + return c.json({ message: "Already data present" }, 400); + } + } catch (err) { + console.error("An error occured while adding time", err); + return c.json({ message: "Internal Server Error" }, 500); + } +}); + +type Group = { + date: string; + totalDuration: number; +} + +type Row = { + date: string; + duration: number | null; +} + +pRouter.get("/get_time", requireAuth, async (c) => { + try { + const { userId } = c.get("authData"); + const data: Row[] = await db + .select({ + duration: productivityTimer.duration, + date: productivityTimer.date, + }) + .from(productivityTimer) + .where(eq(productivityTimer.userId, userId)); + + console.log("Data is =", data); + + const dataFixed = data.reduce((acc, { date, duration }) => { + + const amt = duration ?? 0; + const found = acc.find((ele) => ele.date === date); + if(found) { + found.totalDuration += amt; + } else { + acc.push({ date, totalDuration: amt }); + } + return acc; + }, []); + return c.json({ success: true, dataFixed }, 200); + } catch (err) { + console.error("An error occured while fetching durations per day =", err); + return c.json({ message: "Internal Server Error" }, 500); + } +}); + +export default pRouter; diff --git a/backend/src/routes/taskRoutes.ts b/backend/src/routes/taskRoutes.ts index c64a336..f41e6e4 100644 --- a/backend/src/routes/taskRoutes.ts +++ b/backend/src/routes/taskRoutes.ts @@ -588,7 +588,7 @@ tasksRouter.delete('/delete_task/:id', requireAuth, async (c) => { * content: * application/json: * schema: - * type: object* + * type: object */ tasksRouter.post('/add_list', requireAuth, async (c) => { try { diff --git a/frontend/src/components/dashboard/global_components/miniTimer.tsx b/frontend/src/components/dashboard/global_components/miniTimer.tsx new file mode 100644 index 0000000..86e0e9f --- /dev/null +++ b/frontend/src/components/dashboard/global_components/miniTimer.tsx @@ -0,0 +1,71 @@ +import { useTimer } from "@/context/useTimer"; +import { useMemo } from "react"; +import { TimerTypes } from "@/helpers/helpers"; +import { Square, Pause, Play } from "lucide-react"; + +export default function MiniTimer() { + const { status, workTime, breakTime, timerType, start, pause, stop } = + useTimer(); + + const active = status !== "idle"; + const paused = status === "paused"; + + const handleStopPause = (type: "stop" | "pause") => { + if (type === "stop") stop(); + else pause(); + }; + + const display = useMemo(() => { + const t = Math.max( + 0, + Math.floor(timerType === TimerTypes.WORK ? workTime : breakTime) + ); + const minutes = Math.floor((t % 3600) / 60); + const seconds = t % 60; + + const mm = String(minutes).padStart(2, "0"); + const ss = String(seconds).padStart(2, "0"); + + return ( +

+ {mm}:{ss} +

+ ); + }, [workTime, breakTime, timerType]); + + // if timer is stopped do not show anything + if (!active) { + return; + } + + return ( +
+
+
{timerType === TimerTypes.WORK ? "📔Work Time" : timerType === TimerTypes.BREAK ? "😪Break Time" : "😴Long Break Time"}
+
{display}
+
+
+ handleStopPause("stop")} + > + + + {paused ? ( + + + + ) : ( + handleStopPause("pause")} + > + + + )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/main_screens/analytics_page/analytics.tsx b/frontend/src/components/dashboard/main_screens/analytics_page/analytics.tsx index 359f286..74f8216 100644 --- a/frontend/src/components/dashboard/main_screens/analytics_page/analytics.tsx +++ b/frontend/src/components/dashboard/main_screens/analytics_page/analytics.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { toast } from "sonner"; import { Gauge, gaugeClasses } from "@mui/x-charts/Gauge"; import { RotateCcw } from "lucide-react"; +import MiniTimer from "../../global_components/miniTimer"; interface TaskData { total: number; @@ -51,9 +52,9 @@ export default function Analytics() {

Analytics

-

- View stats, check your progress of work completed. -

+

+ View stats, check your progress of work completed. +

Loading...

@@ -62,85 +63,88 @@ export default function Analytics() { return (
-
-

Analytics

-

- View stats, check your progress of work completed. -

+
+
+

Analytics

+

+ View stats, check your progress of work completed. +

+
+
-
- setReload(v => !v)}> -
-
-

Completed Tasks

- `${value}/${valueMax}`} - sx={(theme) => ({ - [`& .${gaugeClasses.valueText}`]: { - fontSize: 30, - }, - [`& .${gaugeClasses.valueArc}`]: { - fill: "#00c950", - }, - [`& .${gaugeClasses.referenceArc}`]: { - fill: '#f0fdf4', - }, - })} - /> -
-
-

In Progress

- `${value}/${valueMax}`} - sx={(theme) => ({ - [`& .${gaugeClasses.valueText}`]: { - fontSize: 30, - }, - [`& .${gaugeClasses.valueArc}`]: { - fill: "#f0b100", - }, - [`& .${gaugeClasses.referenceArc}`]: { - fill: '#fef9c2', - }, - })} - /> -
-
-

Incomplete

- `${value}/${valueMax}`} - sx={(theme) => ({ - [`& .${gaugeClasses.valueText}`]: { - fontSize: 30, - }, - [`& .${gaugeClasses.valueArc}`]: { - fill: "#fb2c36", - }, - [`& .${gaugeClasses.referenceArc}`]: { - fill: '#ffe2e2', - }, - })} - /> -
+
+
setReload(v => !v)}> +
+
+

Completed Tasks

+ `${value}/${valueMax}`} + sx={(theme) => ({ + [`& .${gaugeClasses.valueText}`]: { + fontSize: 25, + }, + [`& .${gaugeClasses.valueArc}`]: { + fill: "#00c950", + }, + [`& .${gaugeClasses.referenceArc}`]: { + fill: "#f0fdf4", + }, + })} + /> +
+
+

In Progress

+ `${value}/${valueMax}`} + sx={(theme) => ({ + [`& .${gaugeClasses.valueText}`]: { + fontSize: 25, + }, + [`& .${gaugeClasses.valueArc}`]: { + fill: "#f0b100", + }, + [`& .${gaugeClasses.referenceArc}`]: { + fill: "#fef9c2", + }, + })} + /> +
+
+

Incomplete

+ `${value}/${valueMax}`} + sx={(theme) => ({ + [`& .${gaugeClasses.valueText}`]: { + fontSize: 25, + }, + [`& .${gaugeClasses.valueArc}`]: { + fill: "#fb2c36", + }, + [`& .${gaugeClasses.referenceArc}`]: { + fill: "#ffe2e2", + }, + })} + /> +
); diff --git a/frontend/src/components/dashboard/main_screens/home_page/components/CreateList.tsx b/frontend/src/components/dashboard/main_screens/home_page/components/CreateList.tsx index 51b695e..2d12442 100644 --- a/frontend/src/components/dashboard/main_screens/home_page/components/CreateList.tsx +++ b/frontend/src/components/dashboard/main_screens/home_page/components/CreateList.tsx @@ -16,7 +16,7 @@ import { Trash } from "lucide-react"; import { useAuth } from "@clerk/clerk-react"; import { toast } from "sonner"; import { LoaderCircle } from "lucide-react"; -import { capitalizeFirst } from "@/helpers/capitalizeFirst"; +import { capitalizeFirst } from "@/helpers/helpers"; import SelectCategoryDropdown from "./SelectCategoryDropdown"; type PropTypes = { diff --git a/frontend/src/components/dashboard/main_screens/home_page/components/ManageCategories.tsx b/frontend/src/components/dashboard/main_screens/home_page/components/ManageCategories.tsx index bf1bd72..a3b5175 100644 --- a/frontend/src/components/dashboard/main_screens/home_page/components/ManageCategories.tsx +++ b/frontend/src/components/dashboard/main_screens/home_page/components/ManageCategories.tsx @@ -19,7 +19,7 @@ import { useAuth } from "@clerk/clerk-react"; import { toast } from "sonner"; import { Skeleton } from "../../../../ui/skeleton"; import { Button } from "../../../../ui/button"; -import { capitalizeFirst } from "@/helpers/capitalizeFirst"; +import { capitalizeFirst } from "@/helpers/helpers"; type PropTypes = { open: boolean; diff --git a/frontend/src/components/dashboard/main_screens/home_page/components/SelectCategoryDropdown.tsx b/frontend/src/components/dashboard/main_screens/home_page/components/SelectCategoryDropdown.tsx index 005bbd5..a5c891e 100644 --- a/frontend/src/components/dashboard/main_screens/home_page/components/SelectCategoryDropdown.tsx +++ b/frontend/src/components/dashboard/main_screens/home_page/components/SelectCategoryDropdown.tsx @@ -1,5 +1,5 @@ import type { Category } from "@/types/category"; -import { capitalizeFirst } from "@/helpers/capitalizeFirst"; +import { capitalizeFirst } from "@/helpers/helpers"; type PropTypes = { categories: Category[] | undefined; diff --git a/frontend/src/components/dashboard/main_screens/home_page/home.tsx b/frontend/src/components/dashboard/main_screens/home_page/home.tsx index 44996ae..86427a5 100644 --- a/frontend/src/components/dashboard/main_screens/home_page/home.tsx +++ b/frontend/src/components/dashboard/main_screens/home_page/home.tsx @@ -1,16 +1,20 @@ import AITextArea from "./components/AITextArea"; import TaskList from "./components/TaskList"; import { useState } from "react"; +import MiniTimer from "../../global_components/miniTimer"; export default function Home() { const [isUpdated, setIsUpdated] = useState(false); return (
-
-

Dashboard

-

- Generate and manage your AI-powered tasks -

+
+
+

Dashboard

+

+ Generate and manage your AI-powered tasks +

+
+
diff --git a/frontend/src/components/dashboard/main_screens/productivity_zone_page/components/buttons.tsx b/frontend/src/components/dashboard/main_screens/productivity_zone_page/components/buttons.tsx new file mode 100644 index 0000000..5e8130a --- /dev/null +++ b/frontend/src/components/dashboard/main_screens/productivity_zone_page/components/buttons.tsx @@ -0,0 +1,44 @@ +import { Button } from "@/components/ui/button"; +import { Timer } from "lucide-react"; + +interface PropTypes { + handleStart: () => void; + active: boolean; + paused: boolean; + handleStopPause: (type: "stop" | "pause") => void; // accepted inputs -> "pause" / "stop" +} + +export default function TimerButtons({ + handleStart, + active, + paused, + handleStopPause +}: PropTypes) { + + return ( +
+ {!active && ( + + )} + {active && ( +
+ + {paused ? ( + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/main_screens/productivity_zone_page/components/timer.tsx b/frontend/src/components/dashboard/main_screens/productivity_zone_page/components/timer.tsx new file mode 100644 index 0000000..7122ac3 --- /dev/null +++ b/frontend/src/components/dashboard/main_screens/productivity_zone_page/components/timer.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import { TimerTypes } from "@/helpers/helpers"; + +type TimerProps = { + workTime: number; + breakTime: number; + timerType: TimerTypes; +}; // time in seconds + +export default function Timer({ workTime, breakTime, timerType }: TimerProps) { + const display = useMemo(() => { + const t = Math.max(0, Math.floor(timerType === TimerTypes.WORK ? workTime : breakTime)); + const minutes = Math.floor((t % 3600) / 60); + const seconds = t % 60; + + const mm = String(minutes).padStart(2, "0"); + const ss = String(seconds).padStart(2, "0"); + + return ( +

+ {mm}:{ss} +

+ ); + }, [workTime, breakTime, timerType]); + + return ( +
+ {display} +
+ ); +} diff --git a/frontend/src/components/dashboard/main_screens/productivity_zone_page/productivity.tsx b/frontend/src/components/dashboard/main_screens/productivity_zone_page/productivity.tsx new file mode 100644 index 0000000..a3c0f41 --- /dev/null +++ b/frontend/src/components/dashboard/main_screens/productivity_zone_page/productivity.tsx @@ -0,0 +1,99 @@ +import { useState, useMemo } from "react"; +import Timer from "./components/timer"; +import TimerButtons from "./components/buttons"; +import { useTimer } from "@/context/useTimer"; + +export default function Productivity() { + const { + status, + timerType, + workTime, + breakTime, + start, + pause, + stop, + sessionIterations, + sessionTotalWorkTime, + formatHMS, + } = useTimer(); + + const [pomodoroHover, setPomodoroHover] = useState(false); + + const active = status !== "idle"; + const paused = status === "paused"; + + const handleStart = () => start(); + const handleStopPause = (type: "stop" | "pause") => { + if (type === "pause") pause(); + else stop(); + }; + + const timeDisplay = useMemo( + () => formatHMS(sessionTotalWorkTime), + [sessionTotalWorkTime] + ); + + return ( +
+
+

Productivity Zone

+

+ Start studying and working with{" "} + setPomodoroHover(true)} + onPointerLeave={() => setPomodoroHover(false)} + > + POMODORO + {pomodoroHover && ( +

+

+ The Pomodoro Technique is a time-management method that uses a + timer to break work into 25-minute intervals, called + "pomodoros," separated by short 5-minute breaks.{" "} + + Read More + +

+
+ )} + {" "} + technique and boost your productivity +

+
+ +
+
+ + +
+
+ +
+
+ Iterations this session:{" "} + + {Math.floor(sessionIterations)} + +
+
+ Time spent in session:{" "} + {timeDisplay} +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/main_screens/study_page/study.tsx b/frontend/src/components/dashboard/main_screens/study_page/study.tsx deleted file mode 100644 index 76e4450..0000000 --- a/frontend/src/components/dashboard/main_screens/study_page/study.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Study () { - return

Study

-} \ No newline at end of file diff --git a/frontend/src/context/useTimer.tsx b/frontend/src/context/useTimer.tsx new file mode 100644 index 0000000..94ce1d1 --- /dev/null +++ b/frontend/src/context/useTimer.tsx @@ -0,0 +1,268 @@ +import React, { + createContext, useContext, useEffect, useMemo, useRef, useState +} from "react"; +import { + TimerTypes, + breakNotifications, + workNotifications, + createNotification, + longBreakNotifications +} from "@/helpers/helpers"; +import { toast } from "sonner"; +import { useAuth } from "@clerk/clerk-react"; + +type Status = "idle" | "running" | "paused"; + +type TimerContextValue = { + status: Status; + timerType: TimerTypes; + workTime: number; // derived seconds (committed + live) in current WORK block + breakTime: number; // derived seconds in current BREAK block + sessionIterations: number; // +0.5 on each switch + sessionTotalWorkTime: number; // all WORK seconds this session (committed + live) + start: () => void; + pause: () => void; + stop: () => void; + toggle: () => void; + formatHMS: (sec: number) => string; +}; + +const TimerContext = createContext(null); + +const WORK_LIMIT = 25 * 60; // 25:00 +const BREAK_LIMIT = 5 * 60; // 05:00 +const LONG_BREAK_LIMIT = 15 * 60; // larger break time after 4 iterations + +const getNumber = (s: string | null, fallback: number) => { + const n = Number(s); + return Number.isFinite(n) && n >= 0 ? n : fallback; +}; + +const formatHMS = (sec: number) => { + const t = Math.max(0, Math.floor(sec)); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const s = t % 60; + const pad = (x: number) => String(x).padStart(2, "0"); + return `${pad(h)}:${pad(m)}:${pad(s)}`; +}; + +export function TimerProvider({ children }: { children: React.ReactNode }) { + + const { getToken } = useAuth(); + const baseUrl = import.meta.env.PUBLIC_BACKEND_URL; + + // Committed (accumulated) seconds for the *current* block + const [workAccum, setWorkAccum] = useState(() => getNumber(sessionStorage.getItem("work_time"), 0)); + const [breakAccum, setBreakAccum] = useState(() => getNumber(sessionStorage.getItem("break_time"), 0)); + + // Session aggregates + const [sessionWorkAccum, setSessionWorkAccum] = + useState(() => getNumber(sessionStorage.getItem("session_total_work"), 0)); + const [sessionIterations, setSessionIterations] = + useState(() => getNumber(sessionStorage.getItem("session_iterations"), 0)); + + // State + const [status, setStatus] = useState("idle"); + const [timerType, setTimerType] = useState(TimerTypes.WORK); + + // Timestamp for current live segment (null if paused/idle) + const [startedAt, setStartedAt] = useState(null); + + const completedWorkBlocks = useRef(0); + + // Persist (optional—remove if you don’t want persistence) + useEffect(() => sessionStorage.setItem("work_time", String(workAccum)), [workAccum]); + useEffect(() => sessionStorage.setItem("break_time", String(breakAccum)), [breakAccum]); + useEffect(() => sessionStorage.setItem("session_total_work", String(sessionWorkAccum)), [sessionWorkAccum]); + useEffect(() => sessionStorage.setItem("session_iterations", String(sessionIterations)), [sessionIterations]); + + // 1s tick to refresh UI; time itself is derived from Date.now() + const tickRef = useRef(null); + const [, force] = useState(0); + useEffect(() => { + if (status === "running" && !tickRef.current) { + tickRef.current = window.setInterval(() => force(v => v + 1), 1000); + } + if (status !== "running" && tickRef.current) { + clearInterval(tickRef.current); + tickRef.current = null; + } + return () => { + if (tickRef.current) { + clearInterval(tickRef.current); + tickRef.current = null; + } + }; + }, [status]); + + // Live seconds since start of current segment + const liveSec = startedAt && status === "running" + ? Math.floor((Date.now() - startedAt) / 1000) + : 0; + + // Derived block times (committed + live) + const workTime = useMemo( + () => (timerType === TimerTypes.WORK ? workAccum + liveSec : workAccum), + [timerType, workAccum, liveSec] + ); + const breakTime = useMemo( + () => (timerType === TimerTypes.BREAK ? breakAccum + liveSec : breakAccum), + [timerType, breakAccum, liveSec] + ); + + // Session total (include live only during WORK) + const sessionTotalWorkTime = useMemo( + () => sessionWorkAccum + (timerType === TimerTypes.WORK ? liveSec : 0), + [sessionWorkAccum, timerType, liveSec] + ); + + // Auto-switch logic + useEffect(() => { + if (status !== "running") return; + + // WORK -> BREAK + if (timerType === TimerTypes.WORK && workTime >= WORK_LIMIT) { + if (liveSec > 0) setSessionWorkAccum(v => v + liveSec); // commit live to session + setWorkAccum(0); // reset block + completedWorkBlocks.current += 1; + const isLong = completedWorkBlocks.current%4 === 0; + setTimerType(isLong ? TimerTypes.LONG_BREAK: TimerTypes.BREAK); + setStartedAt(Date.now()); // start break immediately + setSessionIterations(i => i + 0.5); + createNotification(isLong ? longBreakNotifications[Math.floor(Math.random() * longBreakNotifications.length)]: breakNotifications[Math.floor(Math.random() * breakNotifications.length)]) + } + + // BREAK -> WORK + if (timerType === TimerTypes.BREAK && breakTime >= BREAK_LIMIT) { + setBreakAccum(0); + setTimerType(TimerTypes.WORK); + setStartedAt(Date.now()); // start work immediately + setSessionIterations(i => i + 0.5); + createNotification(workNotifications[Math.floor(Math.random() * workNotifications.length)]); + } + + if(timerType === TimerTypes.LONG_BREAK && breakTime >= LONG_BREAK_LIMIT) { + setBreakAccum(0); + setTimerType(TimerTypes.WORK); + setStartedAt(Date.now()); + setSessionIterations(i => i+0.5); + createNotification(workNotifications[Math.floor(Math.random() * workNotifications.length)]); + } + }, [status, timerType, workTime, breakTime, liveSec]); + + // Controls + const start = () => { + if (status === "running") return; + const now = Date.now(); + setStatus("running"); + setStartedAt(now); +}; + +const pause = async () => { + if (status !== "running" || !startedAt) { + setStatus("paused"); + return; + } + + const end = Date.now(); + + // update local accumulators + const sec = Math.floor((end - startedAt) / 1000); + if (timerType === TimerTypes.WORK) { + setWorkAccum(v => v + sec); + setSessionWorkAccum(v => v + sec); + } else { + setBreakAccum(v => v + sec); + } + + setStatus("paused"); + + // call backend + try { + const token = await getToken(); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const payload = { + startTime: startedAt, + endTime: end, + timeZone, + }; + const response = await fetch(`${baseUrl}/prod/add`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const resData = await response.json(); + if (!resData.success) toast.error("Error while saving time"); + } catch (err) { + console.error("Pause error", err); + toast.error("Error while saving time"); + } + setStartedAt(null); +}; + +const stop = async () => { + const end = Date.now(); + + if (startedAt) { + const sec = Math.floor((end - startedAt) / 1000); + if (timerType === TimerTypes.WORK) { + setSessionWorkAccum(v => v + sec); + } + } + + setStatus("idle"); + setTimerType(TimerTypes.WORK); + setWorkAccum(0); + setBreakAccum(0); + + try { + const token = await getToken(); + const payload = { + startTime: startedAt, + endTime: end, + }; + const response = await fetch(`${baseUrl}/prod/add?type=stop`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const resData = await response.json(); + if (resData.success) toast.success("Work session saved!"); + } catch (err) { + console.error("Stop error", err); + toast.error("Error while saving time"); + } + setStartedAt(null); +}; + + const toggle = () => (status === "running" ? pause() : start()); + + const value = useMemo(() => ({ + status, + timerType, + workTime, + breakTime, + sessionIterations, + sessionTotalWorkTime, + start, + pause, + stop, + toggle, + formatHMS, + }), [status, timerType, workTime, breakTime, sessionIterations, sessionTotalWorkTime]); + + return {children}; +} + +export function useTimer() { + const ctx = useContext(TimerContext); + if (!ctx) throw new Error("useTimer must be used within "); + return ctx; +} diff --git a/frontend/src/helpers/capitalizeFirst.ts b/frontend/src/helpers/capitalizeFirst.ts deleted file mode 100644 index b5cd382..0000000 --- a/frontend/src/helpers/capitalizeFirst.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function capitalizeFirst(v: string) { - const t = v.charAt(0).toUpperCase() + v.slice(1); - return t; -} \ No newline at end of file diff --git a/frontend/src/helpers/helpers.ts b/frontend/src/helpers/helpers.ts new file mode 100644 index 0000000..6866ff0 --- /dev/null +++ b/frontend/src/helpers/helpers.ts @@ -0,0 +1,54 @@ +export function capitalizeFirst(v: string) { + const t = v.charAt(0).toUpperCase() + v.slice(1); + return t; +} + +export function createNotification(body: string) { + return new Notification("TaskAI", { body: body }); +} + +export const breakNotifications = [ + "🎉 Great job! You've completed 30 minutes of focused work. Time to take a short break!", + "✅ 30 minutes done! Step away for a quick break and recharge.", + "⏳ Half an hour of productivity completed! Treat yourself to a short break.", + "👏 Well done! You’ve worked for 30 minutes straight. Take 5 minutes to relax.", + "⚡ Awesome focus! 30 minutes logged — time for a quick stretch.", + "🌿 30 minutes of work completed! Pause for a short refresh.", + "💡 You’ve been working for 30 minutes. A short break will keep your energy up!", + "⏰ Break time! 30 minutes of solid work achieved.", + "✨ Focus session complete — 30 minutes done! Take a short break before resuming.", + "🚀 Productivity boost: You’ve worked 30 minutes nonstop. Now, recharge with a break." +]; + +export const workNotifications = [ + "💪 Break’s over! Let’s get back to another 30 minutes of focused work.", + "🚀 Ready to dive in? Start your next 30-minute session now!", + "⏰ Time to get back on track. Begin your next focus block.", + "✨ Refreshed? Let’s crush the next 30 minutes of work!", + "🔥 Break complete! Jump back into your workflow.", + "📚 Time to focus again. Start your next productive session!", + "🌟 Let’s build momentum — your next 30 minutes of work starts now.", + "✅ Recharged and ready? Continue with your next focus session.", + "⚡ Back to work mode! Another 30-minute sprint begins.", + "👏 You’re doing great! Time to start the next work session." +]; + +export const longBreakNotifications: string[] = [ + "🎉 You worked hard! Time to reset and refresh — take a long break 🛋️", + "👏 Awesome focus! Reward yourself with a longer rest now ☕", + "💪 Great job finishing four sessions. Breathe, relax, and recharge 🌿", + "🔥 Consistency pays off! Take a well-deserved long break 😌", + "🌟 You’ve earned it — step away and enjoy your long break 🌴", + "🏆 Fantastic work streak! Time to relax and clear your mind 🧘", + "💯 Well done! Recharge with a long break before the next round ⚡", + "🚀 Focus mode complete — now it’s relaxation mode. Take your long break 💤", + "🌈 Solid effort! Give your brain and body a proper reset 🧃", + "✨ Productivity achieved! Unwind and refresh during this long break 🎶" +]; + + +export enum TimerTypes { + WORK = "work", + BREAK = "break", + LONG_BREAK = "long_break" +} \ No newline at end of file diff --git a/frontend/src/islands/ClientDashboard.tsx b/frontend/src/islands/ClientDashboard.tsx index 60ad844..b028bb9 100644 --- a/frontend/src/islands/ClientDashboard.tsx +++ b/frontend/src/islands/ClientDashboard.tsx @@ -1,10 +1,11 @@ import ClerkProviderWrapper from '@/components/ClerkProviderWrapper'; import Home from '@/components/dashboard/main_screens/home_page/home'; import Analytics from '@/components/dashboard/main_screens/analytics_page/analytics'; -import Study from '@/components/dashboard/main_screens/study_page/study'; import Settings from '@/components/dashboard/main_screens/settings_page/settings'; import Sidebar from './Sidebar'; import { useState } from 'react'; +import Productivity from '@/components/dashboard/main_screens/productivity_zone_page/productivity'; +import { TimerProvider } from '@/context/useTimer'; const ClientDashboard = () => { @@ -13,19 +14,21 @@ const ClientDashboard = () => { return ( +
{activeScreen === "home" && } {activeScreen === "analytics" && } - {activeScreen === "study" && } + {activeScreen === "productivity" && } {activeScreen === "settings" && }
+
) } diff --git a/frontend/src/islands/Sidebar.tsx b/frontend/src/islands/Sidebar.tsx index cc824a4..3493de5 100644 --- a/frontend/src/islands/Sidebar.tsx +++ b/frontend/src/islands/Sidebar.tsx @@ -14,12 +14,14 @@ import type { Dispatch, SetStateAction } from "react"; import clsx from "clsx"; interface PropTypes { + activeScreen: String; setActiveScreen: Dispatch>; collapsed: boolean; setCollapsed: Dispatch>; } export default function Sidebar({ + activeScreen, setActiveScreen, collapsed, setCollapsed, @@ -40,9 +42,9 @@ export default function Sidebar({ click: "analytics", }, { - name: "Study Zone", + name: "Productivity Zone", icon: , - click: "study", + click: "productivity", }, { name: "Settings", @@ -119,8 +121,9 @@ export default function Sidebar({ className={clsx( "mb-3 text-sm flex items-center p-2 gap-4 cursor-pointer", collapsed - ? "justify-center rounded-full" - : "rounded-lg hover:bg-violet-300 hover:shadow-sm transition-colors" + ? "justify-center rounded-lg py-2 px-4" + : "rounded-lg hover:bg-violet-300 hover:shadow-sm transition-colors", + activeScreen === option.click ? "bg-violet-300" : "" )} onClick={() => setActiveScreen(option.click)} title={collapsed ? option.name : undefined} diff --git a/frontend/src/pages/dashboard.astro b/frontend/src/pages/dashboard.astro index ae07959..f436f2b 100644 --- a/frontend/src/pages/dashboard.astro +++ b/frontend/src/pages/dashboard.astro @@ -14,6 +14,6 @@ if (!userId) { description="User dashbaord, create tasklist, edit tasklist, manage your tasks. Use AI to generate your tasks and manage your life." >
- +