diff --git a/artifacts/sunsama-web/index.html b/artifacts/sunsama-web/index.html new file mode 100644 index 0000000..1995b2b --- /dev/null +++ b/artifacts/sunsama-web/index.html @@ -0,0 +1,15 @@ + + + + + + Sunsama + + + + + +
+ + + diff --git a/artifacts/sunsama-web/package.json b/artifacts/sunsama-web/package.json new file mode 100644 index 0000000..687eb05 --- /dev/null +++ b/artifacts/sunsama-web/package.json @@ -0,0 +1,29 @@ +{ + "name": "@workspace/sunsama-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@radix-ui/react-popover": "^1.1.15", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "date-fns": "^3.6.0", + "lucide-react": "catalog:", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "vite": "catalog:" + } +} diff --git a/artifacts/sunsama-web/src/App.tsx b/artifacts/sunsama-web/src/App.tsx new file mode 100644 index 0000000..4feb104 --- /dev/null +++ b/artifacts/sunsama-web/src/App.tsx @@ -0,0 +1,5 @@ +import { HomePage } from "./pages/home/Homepage"; + +export default function App() { + return ; +} diff --git a/artifacts/sunsama-web/src/index.css b/artifacts/sunsama-web/src/index.css new file mode 100644 index 0000000..0ca1172 --- /dev/null +++ b/artifacts/sunsama-web/src/index.css @@ -0,0 +1,15 @@ +@import "tailwindcss"; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; +} diff --git a/artifacts/sunsama-web/src/main.tsx b/artifacts/sunsama-web/src/main.tsx new file mode 100644 index 0000000..c2a145c --- /dev/null +++ b/artifacts/sunsama-web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/artifacts/sunsama-web/src/pages/home/BacklogPanel.tsx b/artifacts/sunsama-web/src/pages/home/BacklogPanel.tsx new file mode 100644 index 0000000..29d0327 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/BacklogPanel.tsx @@ -0,0 +1,85 @@ +import "./homepage.css"; +import { useState } from "react"; +import type { Project } from "./types"; +import { X, Search, RotateCcw } from "lucide-react"; + +interface Props { + archivedProjects: Project[]; + onRestore: (projectId: string) => void; + onClose: () => void; +} + +export default function BacklogPanel({ + archivedProjects, + onRestore, + onClose, +}: Props) { + const [search, setSearch] = useState(""); + const [filterTag, setFilterTag] = useState("all"); + + const filtered = archivedProjects.filter((p) => { + const matchesSearch = + !search || p.title.toLowerCase().includes(search.toLowerCase()); + const matchesTag = filterTag === "all" || p.tag === filterTag; + return matchesSearch && matchesTag; + }); + + return ( + + ); +} diff --git a/artifacts/sunsama-web/src/pages/home/BoardHeader.tsx b/artifacts/sunsama-web/src/pages/home/BoardHeader.tsx new file mode 100644 index 0000000..f91dbd9 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/BoardHeader.tsx @@ -0,0 +1,34 @@ +import "./homepage.css"; +import { + Calendar, + SlidersHorizontal, + LayoutGrid, + CalendarDays, +} from "lucide-react"; + +export default function BoardHeader() { + return ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/artifacts/sunsama-web/src/pages/home/CalendarPane.tsx b/artifacts/sunsama-web/src/pages/home/CalendarPane.tsx new file mode 100644 index 0000000..4150291 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/CalendarPane.tsx @@ -0,0 +1,100 @@ +import "./homepage.css"; +import { useState, useEffect, useMemo } from "react"; +import { + Calendar, + BarChart2, + Settings, + ClipboardList, + Moon, + Search, + Zap, + Plus, +} from "lucide-react"; + +const HOURS = Array.from({ length: 12 }, (_, i) => i + 12); +const HOUR_HEIGHT = 56; + +function currentMinuteOffset(): number { + const now = new Date(); + const h = now.getHours(); + const m = now.getMinutes(); + const minutesSinceNoon = (h - 12) * 60 + m; + return (minutesSinceNoon / 60) * HOUR_HEIGHT; +} + +interface Props { + onOpenBacklog: () => void; +} + +export default function CalendarPane({ onOpenBacklog }: Props) { + const now = new Date(); + const dayAbbr = now + .toLocaleDateString("en-US", { weekday: "short" }) + .toUpperCase(); + const dayNum = now.getDate(); + + const [timeOffset, setTimeOffset] = useState(currentMinuteOffset); + + useEffect(() => { + const id = setInterval(() => setTimeOffset(currentMinuteOffset()), 60_000); + return () => clearInterval(id); + }, []); + + const showTimeLine = useMemo(() => { + const h = now.getHours(); + return h >= 12 && h < 24; + }, [now]); + + return ( + + ); +} diff --git a/artifacts/sunsama-web/src/pages/home/CalendarSelect.tsx b/artifacts/sunsama-web/src/pages/home/CalendarSelect.tsx new file mode 100644 index 0000000..4883f2f --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/CalendarSelect.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { CalendarIcon } from "lucide-react"; +import { DayPicker } from "react-day-picker"; +import { format, isToday } from "date-fns"; +import "react-day-picker/style.css"; + +interface Props { + value?: string; + onSelect?: (dateString: string) => void; +} + +export default function CalendarSelect({ value, onSelect }: Props) { + const initial = value ? new Date(value) : new Date(); + const [date, setDate] = useState(initial); + const [open, setOpen] = useState(false); + + function handleSelect(d: Date | undefined) { + if (!d) return; + setDate(d); + const iso = format(d, "yyyy-MM-dd"); + if (onSelect) onSelect(iso); + setOpen(false); + } + + return ( + + + + + + +
+ Schedule exact start date +
+ +
+
+ ); +} diff --git a/artifacts/sunsama-web/src/pages/home/DayColumn.tsx b/artifacts/sunsama-web/src/pages/home/DayColumn.tsx new file mode 100644 index 0000000..1b14f03 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/DayColumn.tsx @@ -0,0 +1,42 @@ +import type { DayItem, Project } from "./types"; +import "./homepage.css"; +import TaskCard from "./TaskCard"; + +interface Props { + day: DayItem; + projects: Project[]; + openEditor: (dayId: string) => void; + pendingToggles: Record; + onToggleSubtask: (projectId: string, subtaskTitle: string) => void; + onArchiveTask: (projectId: string) => void; +} + +export default function DayColumn({ + day, + projects, + openEditor, + pendingToggles, + onToggleSubtask, + onArchiveTask, +}: Props) { + return ( +
+
+
{day.name}
+
{day.date}
+
+ + {projects.map((p) => ( + + ))} +
+ ); +} diff --git a/artifacts/sunsama-web/src/pages/home/Homepage.tsx b/artifacts/sunsama-web/src/pages/home/Homepage.tsx new file mode 100644 index 0000000..173c931 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/Homepage.tsx @@ -0,0 +1,170 @@ +import "./homepage.css"; +import { useRef, useEffect, useState, useMemo } from "react"; +import type { Project } from "./types"; +import { generateDays } from "./generateDays"; +import { sampleProjects } from "./sampleData"; +import Sidebar from "./Sidebar"; +import BoardHeader from "./BoardHeader"; +import DayColumn from "./DayColumn"; +import TaskEditor from "./TaskEditor"; +import CalendarPane from "./CalendarPane"; +import BacklogPanel from "./BacklogPanel"; + +export function HomePage() { + const days = useMemo(() => generateDays(), []); + const todayIndex = 29; + const daysRef = useRef(null); + + const [localProjects, setLocalProjects] = + useState(sampleProjects); + const [pendingToggles, setPendingToggles] = useState< + Record + >({}); + + const [editor, setEditor] = useState<{ + open: boolean; + dayId?: string; + title?: string; + description?: string; + tag?: string; + timeEstimate?: number; + }>({ open: false }); + + const [showBacklog, setShowBacklog] = useState(false); + + function openEditor(dayId: string) { + setEditor({ + open: true, + dayId, + title: "", + description: "", + tag: "# work", + timeEstimate: 30, + }); + } + + function closeEditor() { + setEditor({ open: false }); + } + + function saveEditor() { + if (!editor.dayId) return closeEditor(); + const newTask: Project = { + _id: `local-${Date.now()}`, + title: editor.title || "New task", + date: editor.dayId, + plannedTime: editor.timeEstimate ?? 0, + subtasks: [], + tag: editor.tag, + }; + + setLocalProjects((prev) => [newTask, ...prev]); + closeEditor(); + } + + function handleWheel(e: React.WheelEvent) { + const el = daysRef.current; + if (!el) return; + if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + el.scrollLeft += e.deltaY; + e.preventDefault(); + } + } + + useEffect(() => { + const container = daysRef.current; + const todayEl = container?.children[todayIndex - 1] as + | HTMLElement + | undefined; + if (!container || !todayEl) return; + container.scrollTo({ left: todayEl.offsetLeft, behavior: "instant" }); + }, [todayIndex]); + + function handleToggleSubtask(projectId: string, subtaskTitle: string) { + const toggleKey = `${projectId}::${subtaskTitle}`; + if (pendingToggles[toggleKey]) return; + + setLocalProjects((prev) => + prev.map((p) => { + if (p._id !== projectId) return p; + return { + ...p, + subtasks: p.subtasks.map((s) => + s.title === subtaskTitle ? { ...s, isDone: !s.isDone } : s, + ), + }; + }), + ); + + setPendingToggles((prev) => ({ ...prev, [toggleKey]: true })); + setTimeout(() => { + setPendingToggles((prev) => { + const copy = { ...prev }; + delete copy[toggleKey]; + return copy; + }); + }, 300); + } + + function handleArchiveTask(projectId: string) { + setLocalProjects((prev) => + prev.map((p) => + p._id === projectId ? { ...p, archived: true } : p, + ), + ); + } + + function handleRestoreTask(projectId: string) { + setLocalProjects((prev) => + prev.map((p) => + p._id === projectId ? { ...p, archived: false } : p, + ), + ); + } + + const activeProjects = localProjects.filter((p) => !p.archived); + const archivedProjects = localProjects.filter((p) => p.archived); + + return ( +
+ + +
+ + +
+ {days.map((day) => ( + p.date === day.id)} + openEditor={openEditor} + pendingToggles={pendingToggles} + onToggleSubtask={handleToggleSubtask} + onArchiveTask={handleArchiveTask} + /> + ))} +
+ + {editor.open && ( + + )} +
+ + {showBacklog ? ( + setShowBacklog(false)} + /> + ) : ( + setShowBacklog(true)} /> + )} +
+ ); +} diff --git a/artifacts/sunsama-web/src/pages/home/ProgressBar.tsx b/artifacts/sunsama-web/src/pages/home/ProgressBar.tsx new file mode 100644 index 0000000..43b185c --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/ProgressBar.tsx @@ -0,0 +1,18 @@ +import "./homepage.css"; + +interface ProgressBarProps { + progress: number; +} + +export default function ProgressBar({ progress }: ProgressBarProps) { + const clampProgress = Math.min(Math.max(progress, 0), 100); + + return ( +
+
+
+ ); +} diff --git a/artifacts/sunsama-web/src/pages/home/Sidebar.tsx b/artifacts/sunsama-web/src/pages/home/Sidebar.tsx new file mode 100644 index 0000000..f5082c2 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/Sidebar.tsx @@ -0,0 +1,79 @@ +import "./homepage.css"; +import { useState } from "react"; +import { + Home, + ListTodo, + Crosshair, + Calendar, + FileText, + Pencil, + CheckSquare, + ChevronLeft, + Users, +} from "lucide-react"; + +export default function Sidebar() { + const [collapsed, setCollapsed] = useState(false); + + return ( + + ); +} diff --git a/artifacts/sunsama-web/src/pages/home/SubtaskItem.tsx b/artifacts/sunsama-web/src/pages/home/SubtaskItem.tsx new file mode 100644 index 0000000..6902743 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/SubtaskItem.tsx @@ -0,0 +1,38 @@ +import type { Subtask } from "./types"; +import "./homepage.css"; +import { Circle, CheckCircle2 } from "lucide-react"; + +interface Props { + projectId: string; + subtask: Subtask; + inFlight: boolean; + onToggle: (projectId: string, subtaskTitle: string) => void; +} + +export default function SubtaskItem({ + projectId, + subtask, + inFlight, + onToggle, +}: Props) { + return ( +
+ { + if (!inFlight) onToggle(projectId, subtask.title); + }} + > + {subtask.isDone ? ( + + ) : ( + + )} + +
+ {subtask.title} +
+
+ ); +} diff --git a/artifacts/sunsama-web/src/pages/home/TaskCard.tsx b/artifacts/sunsama-web/src/pages/home/TaskCard.tsx new file mode 100644 index 0000000..96f3bd7 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/TaskCard.tsx @@ -0,0 +1,68 @@ +import "./homepage.css"; +import ProgressBar from "./ProgressBar"; +import type { Project, Subtask } from "./types"; +import SubtaskItem from "./SubtaskItem"; +import { MessageSquare, Clock, Archive } from "lucide-react"; + +interface Props { + project: Project; + pendingToggles: Record; + onToggleSubtask: (projectId: string, subtaskTitle: string) => void; + onArchiveTask: (projectId: string) => void; +} + +export default function TaskCard({ + project, + pendingToggles, + onToggleSubtask, + onArchiveTask, +}: Props) { + const total = project.subtasks?.length || 0; + const done = + project.subtasks?.filter((s: Subtask) => s.isDone).length || 0; + const progress = total > 0 ? Math.round((done / total) * 100) : 0; + + return ( +
+
+
+
{project.title}
+
+ {Math.floor((project.plannedTime ?? 0) / 60)}: + {String((project.plannedTime ?? 0) % 60).padStart(2, "0")} +
+
+ +
+ {project.subtasks?.map((subtask: Subtask, idx: number) => { + const toggleKey = `${project._id}::${subtask.title}`; + const inFlight = !!pendingToggles[toggleKey]; + return ( + + ); + })} +
+
+
+ + + +
+
{project.tag ?? "# work"}
+
+
+
+ ); +} diff --git a/artifacts/sunsama-web/src/pages/home/TaskEditor.tsx b/artifacts/sunsama-web/src/pages/home/TaskEditor.tsx new file mode 100644 index 0000000..a74d169 --- /dev/null +++ b/artifacts/sunsama-web/src/pages/home/TaskEditor.tsx @@ -0,0 +1,140 @@ +import "./homepage.css"; +import type { Dispatch, SetStateAction } from "react"; +import { format, isToday, isTomorrow, isYesterday } from "date-fns"; +import CalendarSelect from "./CalendarSelect"; +import { Clock, Hash, Settings, ArrowUp } from "lucide-react"; + +interface EditorState { + open: boolean; + dayId?: string; + title?: string; + description?: string; + tag?: string; + timeEstimate?: number; +} + +interface Props { + editor: EditorState; + setEditor: Dispatch>; + closeEditor: () => void; + saveEditor: () => void; +} + +export default function TaskEditor({ + editor, + setEditor, + closeEditor, + saveEditor, +}: Props) { + let displayDate: string; + if (editor.dayId) { + const parsed = new Date(editor.dayId + "T00:00:00"); + if (isToday(parsed)) { + displayDate = "Today"; + } else if (isTomorrow(parsed)) { + displayDate = "Tomorrow"; + } else if (isYesterday(parsed)) { + displayDate = "Yesterday"; + } else { + displayDate = format(parsed, "EEEE, MMM d"); + } + } else { + displayDate = format(new Date(), "EEEE, MMM d"); + } + + void displayDate; + + return ( + <> +
+
+ + setEditor((s) => ({ ...s, title: e.target.value })) + } + onKeyDown={(e) => { + if (e.key === "Escape") { + closeEditor(); + } else if (e.key === "Enter") { + e.preventDefault(); + saveEditor(); + } + }} + /> + +