feat(calendar-month-view): Monatsansicht für TimeEntries (Grid 6 weeks × 7 days) [tsc:fail]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 07:19:49 +02:00
parent a3452bda75
commit fb6adcf85a
5 changed files with 335 additions and 72 deletions

View File

@ -8,6 +8,7 @@
"recent-projects-quick-access", "recent-projects-quick-access",
"time-entry-templates", "time-entry-templates",
"dark-mode-improvements", "dark-mode-improvements",
"api-client-phase16" "api-client-phase16",
"router-phase16"
] ]
} }

5
.phase17-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"completed_features": [],
"current_feature": "calendar-month-view",
"started_at": "2026-05-23T07:18:43.778897"
}

View File

@ -2009,3 +2009,27 @@ src/index.ts(27,25): error TS2769: No overload matches this call.
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
- `07:17:23` **INFO** Committed feature router-phase16
- `07:17:23` **INFO** Pushed: rc=0
## Phase-16 Run beendet (2026-05-23 07:17:23)
- `07:17:23` **INFO** OK: 0, Attempted: 7, Total: 7
## 🚀 Phase-17 Codegen-Run gestartet (2026-05-23 07:18:43)
## Phase-3 Feature: calendar-month-view (2026-05-23 07:18:43)
- `07:18:43` **INFO** Description: Monatsansicht für TimeEntries (Grid 6 weeks × 7 days)
- `07:18:43` **INFO** Generating apps/web/src/pages/Calendar.tsx (ERWEITERT — behalte Week-View. Füge View-Toggle (Week / Month). Month-…)
- `07:19:47` **INFO** wrote 7506 chars in 63.7s (attempt 1)
- `07:19:47` **INFO** Running tsc --noEmit on api…
- `07:19:49` **WARN** tsc errors:
src/index.ts(27,25): error TS2769: No overload matches this call.
Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'.
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy

View File

@ -1,107 +1,174 @@
import React, { useState, useMemo } from "react" import React, { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { api } from "../lib/api" import { api } from "../lib/api"
import { format, startOfWeek, addDays, endOfWeek, eachDayOfInterval, isSameDay } from "date-fns" import {
import { ChevronLeft, ChevronRight } from "lucide-react" format,
startOfWeek,
addDays,
endOfWeek,
eachDayOfInterval,
isSameDay,
startOfMonth,
endOfMonth,
startOfmonth,
addMonths,
subMonths,
getDaysInMonth,
startOfDay
} from "date-fns"
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, List } from "lucide-react"
type ViewMode = "week" | "month"
export default function CalendarPage() { export default function CalendarPage() {
const [currentWeekStart, setCurrentWeekStart] = useState(startOfWeek(new Date(), { weekStartsOn: 1 })) const [viewMode, setViewMode] = useState<ViewMode>("week")
const [currentDate, setCurrentDate] = useState(new Date())
const weekEnd = endOfWeek(currentWeekStart, { weekStartsOn: 1 }) // Week View Logic
const days = eachDayOfInterval({ start: currentWeekStart, end: weekEnd }) const weekStart = useMemo(() => startOfWeek(currentDate, { weekStartsOn: 1 }), [currentDate])
const weekEnd = useMemo(() => endOfWeek(weekStart, { weekStartsOn: 1 }), [weekStart])
const weekDays = useMemo(() => eachDayOfInterval({ start: weekStart, end: weekEnd }), [weekStart, weekEnd])
// Month View Logic
const monthStart = useMemo(() => startOfMonth(currentDate), [currentDate])
const monthEnd = useMemo(() => endOfMonth(currentDate), [currentDate])
// To make a perfect grid, we find the start of the first week of the month
const monthGridStart = useMemo(() => startOfWeek(monthStart, { weekStartsOn: 1 }), [monthStart])
const monthGridEnd = useMemo(() => addDays(endOfWeek(monthEnd, { weekStartsOn: 1 }), 0), [monthEnd])
const monthDays = useMemo(() => eachDayOfInterval({ start: monthGridStart, end: monthGridEnd }), [monthGridStart, monthGridEnd])
const { data: entries = [], isLoading } = useQuery({ const { data: entries = [], isLoading } = useQuery({
queryKey: ["time-entries", currentWeekStart.toISOString()], queryKey: ["time-entries", viewMode, currentDate.toISOString()],
queryFn: () => api.listTimeEntries({ queryFn: () => {
from: currentWeekStart.toISOString(), const from = viewMode === "week" ? weekStart.toISOString() : monthStart.toISOString()
to: weekEnd.toISOString() const to = viewMode === "week" ? weekEnd.toISOString() : monthEnd.toISOString()
}), return api.listTimeEntries({ from, to })
},
}) })
const navigateWeek = (direction: "prev" | "next") => { const navigate = (direction: "prev" | "next") => {
const offset = direction === "prev" ? -7 : 7 if (viewMode === "week") {
setCurrentWeekStart(prev => addDays(prev, offset)) setCurrentDate(prev => addDays(prev, direction === "prev" ? -7 : 7))
} else {
setCurrentDate(prev => direction === "prev" ? subMonths(prev, 1) : addMonths(prev, 1))
}
} }
const dayData = useMemo(() => { const calculateDayStats = (day: Date) => {
return days.map(day => { const dayEntries = entries.filter(e => isSameDay(new Date(e.startTime), day))
const dayEntries = entries.filter(e => { const totalHours = dayEntries.reduce((sum, e) => {
const start = new Date(e.startTime) const start = new Date(e.startTime)
return isSameDay(start, day) const end = e.endTime ? new Date(e.endTime) : new Date()
}) return sum + (end.getTime() - start.getTime()) / (1000 * 60 * 60)
}, 0)
const totalHours = dayEntries.reduce((sum, e) => { return { dayEntries, totalHours }
const start = new Date(e.startTime) }
const end = e.endTime ? new Date(e.endTime) : new Date()
return sum + (end.getTime() - start.getTime()) / (1000 * 60 * 60)
}, 0)
return { day, entries: dayEntries, totalHours }
})
}, [days, entries])
if (isLoading) return <div className="p-8 text-center">Loading calendar...</div> if (isLoading) return <div className="p-8 text-center">Loading calendar...</div>
return ( return (
<div className="flex flex-col h-full p-4 gap-4"> <div className="flex flex-col h-full p-4 gap-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Calendar</h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-sm font-medium"> <h1 className="text-2xl font-bold">Calendar</h1>
{format(currentWeekStart, "MMM d")} - {format(weekEnd, "MMM d, yyyy")} <div className="flex bg-gray-100 p-1 rounded-lg border">
</div>
<div className="flex gap-1">
<button <button
onClick={() => navigateWeek("prev")} onClick={() => setViewMode("week")}
className="p-2 hover:bg-gray-100 rounded-full border transition-colors" className={`px-3 py-1 text-sm rounded-md transition-all ${viewMode === "week" ? "bg-white shadow-sm font-bold" : "text-gray-500"}`}
> >
<ChevronLeft className="w-5 h-5" /> Week
</button> </button>
<button <button
onClick={() => navigateWeek("next")} onClick={() => setViewMode("month")}
className="p-2 hover:bg-gray-100 rounded-full border transition-colors" className={`px-3 py-1 text-sm rounded-md transition-all ${viewMode === "month" ? "bg-white shadow-sm font-bold" : "text-gray-500"}`}
> >
Month
</button>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-sm font-medium">
{viewMode === "week"
? `${format(weekStart, "MMM d")} - ${format(weekEnd, "MMM d, yyyy")}`
: format(currentDate, "MMMM yyyy")
}
</div>
<div className="flex gap-1">
<button onClick={() => navigate("prev")} className="p-2 hover:bg-gray-100 rounded-full border transition-colors">
<ChevronLeft className="w-5 h-5" />
</button>
<button onClick={() => navigate("next")} className="p-2 hover:bg-gray-100 rounded-full border transition-colors">
<ChevronRight className="w-5 h-5" /> <ChevronRight className="w-5 h-5" />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-7 gap-2 flex-1 min-h-0"> {viewMode === "week" ? (
{dayData.map(({ day, entries, totalHours }) => ( <div className="grid grid-cols-7 gap-2 flex-1 min-h-0">
<div key={day.toISOString()} className="flex flex-col bg-gray-50 rounded-lg border border-gray-200 overflow-hidden"> {weekDays.map(day => {
<div className="p-2 text-center border-b bg-white"> const { dayEntries, totalHours } = calculateDayStats(day)
<div className="text-xs uppercase text-gray-500 font-semibold"> return (
{format(day, "EEE")} <div key={day.toISOString()} className="flex flex-col bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
</div> <div className="p-2 text-center border-b bg-white">
<div className="text-lg font-bold"> <div className="text-xs uppercase text-gray-500 font-semibold">{format(day, "EEE")}</div>
{format(day, "d")} <div className="text-lg font-bold">{format(day, "d")}</div>
</div> <div className="text-xs font-medium text-blue-600">{totalHours.toFixed(2)}h</div>
<div className="text-xs font-medium text-blue-600"> </div>
{totalHours.toFixed(2)}h <div className="flex-1 overflow-y-auto p-2 space-y-2">
{dayEntries.length === 0 ? (
<div className="text-center text-gray-400 text-xs py-4 italic">No entries</div>
) : (
dayEntries.map(entry => (
<div key={entry.id} className="p-2 bg-white border rounded text-xs shadow-sm hover:border-blue-300 transition-colors">
<div className="font-bold truncate" title={entry.description}>{entry.description}</div>
<div className="text-gray-500 mt-1">
{format(new Date(entry.startTime), "HH:mm")} - {entry.endTime ? format(new Date(entry.endTime), "HH:mm") : "..."}
</div>
</div>
))
)}
</div>
</div> </div>
)
})}
</div>
) : (
<div className="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(d => (
<div key={d} className="bg-gray-50 p-2 text-center text-xs font-bold text-gray-500 uppercase">
{d}
</div> </div>
))}
<div className="flex-1 overflow-y-auto p-2 space-y-2"> {monthDays.map(day => {
{entries.length === 0 ? ( const { totalHours } = calculateDayStats(day)
<div className="text-center text-gray-400 text-xs py-4 italic">No entries</div> const isCurrentMonth = day.getMonth() === currentDate.getMonth()
) : ( return (
entries.map(entry => ( <button
<div key={entry.id} className="p-2 bg-white border rounded text-xs shadow-sm hover:border-blue-300 transition-colors"> key={day.toISOString()}
<div className="font-bold truncate" title={entry.description}> onClick={() => {
{entry.description} setCurrentDate(day)
</div> setViewMode("week")
<div className="text-gray-500 mt-1"> }}
{format(new Date(entry.startTime), "HH:mm")} - className={`h-24 p-2 transition-colors text-left relative ${
{entry.endTime ? format(new Date(entry.endTime), " HH:mm") : " ..."} isCurrentMonth ? "bg-white hover:bg-blue-50" : "bg-gray-50 text-gray-400 hover:bg-gray-100"
</div> }`}
>
<span className={`text-sm font-medium ${isCurrentMonth ? "text-gray-700" : "text-gray-400"}`}>
{format(day, "d")}
</span>
{totalHours > 0 && (
<div className="absolute bottom-2 right-2 px-1.5 py-0.5 bg-blue-100 text-blue-700 text-[10px] font-bold rounded">
{totalHours.toFixed(1)}h
</div> </div>
)) )}
)} </button>
</div> )
</div> })}
))} </div>
</div> )}
</div> </div>
) )
} }

166
scripts/phase17_features.py Normal file
View File

@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Phase-17: drag-drop-reorder, calendar-month, batch-rename, customer-merge, smart-filter-suggestions."""
from __future__ import annotations
import asyncio
import datetime
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from phase2_features import Feature, FileGen, ROOT, log, log_section # noqa: E402
from phase3_features import run_feature_v2 # noqa: E402
PHASE17_STATE = ROOT / ".phase17-state.json"
FEATURES: list[Feature] = [
Feature(
name="calendar-month-view",
description="Monatsansicht für TimeEntries (Grid 6 weeks × 7 days)",
files=[
FileGen(
path="apps/web/src/pages/Calendar.tsx",
purpose=(
"ERWEITERT — behalte Week-View. Füge View-Toggle (Week / Month). "
"Month-View: Grid 7 Spalten, 6 Reihen, jeder Cell zeigt Tagesnummer + total-hours Badge. "
"Vor/zurück-Buttons für Monat-Navigation. Klick auf Day-Cell wechselt zu Week-View dieser Woche."
),
refs=["apps/web/src/pages/Calendar.tsx"],
),
],
),
Feature(
name="batch-rename-projects",
description="Bulk-Select + rename Projects via Mutation",
files=[
FileGen(
path="apps/web/src/pages/Projects.tsx",
purpose=(
"ERWEITERT — füge Checkbox-Spalte. Wenn min 1 selektiert, Action-Bar oben: "
"'Rename mit Prefix...' Input → bei Submit ruft api.bulkRenameProjects(ids, prefix). "
"Plus Bulk-Delete-Button."
),
refs=["apps/web/src/pages/Projects.tsx"],
),
FileGen(
path="apps/api/src/routes/projects.ts",
purpose=(
"ERWEITERT — füge POST /bulk-rename (body: {ids, prefix}): updated jedes Project's name = prefix + ' ' + currentName. "
"Plus POST /bulk-delete (ids[]) → delete all. Behalte alles."
),
refs=["apps/api/src/routes/projects.ts"],
),
],
),
Feature(
name="customer-merge",
description="Merge zwei Customers: source-Projects auf target umhängen, dann source löschen",
files=[
FileGen(
path="apps/api/src/routes/customers.ts",
purpose=(
"ERWEITERT — füge POST /:id/merge (body: {targetId}): "
"alle projects vom source-customer (id) bekommen targetId als customerId, dann delete source-customer. "
"Admin-only. Behalte alles."
),
refs=["apps/api/src/routes/customers.ts"],
),
FileGen(
path="apps/web/src/pages/Customers.tsx",
purpose=(
"ERWEITERT — Merge-Button pro Row (admin-only). Modal mit Target-Customer-Picker, Submit → "
"api.mergeCustomers(sourceId, targetId), refetch."
),
refs=["apps/web/src/pages/Customers.tsx"],
),
],
),
Feature(
name="smart-filter-suggestions",
description="Saved-Views-Vorschläge basierend auf häufig benutzten Filters",
files=[
FileGen(
path="apps/web/src/components/SmartFilters.tsx",
purpose=(
"SmartFilters-Component. Zeigt 3-4 vorgeschlagene Filter-Buttons: "
"'Diese Woche', 'Letzter Monat', 'Heute', 'Nur ohne Projekt'. "
"Props: onApply(filterObject). Klick wendet Filter sofort an."
),
),
FileGen(
path="apps/web/src/pages/TimeEntries.tsx",
purpose=(
"ERWEITERT — behalte alles. Füge <SmartFilters onApply={setFilters} /> oberhalb Filter-Bar."
),
refs=["apps/web/src/pages/TimeEntries.tsx"],
),
],
),
Feature(
name="time-entry-quick-edit",
description="Inline-Edit für TimeEntry-Description (Klick auf Description = Input)",
files=[
FileGen(
path="apps/web/src/pages/TimeEntries.tsx",
purpose=(
"ERWEITERT — in der Liste: Description-Zelle wird beim Klick zu Input (useState editingId). "
"On Blur oder Enter: api.updateTimeEntry(id, {description}). Cancel via Escape."
),
refs=["apps/web/src/pages/TimeEntries.tsx"],
),
],
),
Feature(
name="api-client-phase17",
description="API um bulk-rename, customer-merge erweitert",
files=[
FileGen(
path="apps/web/src/lib/api.ts",
purpose=(
"ERWEITERT — behalte ALLES. Füge: bulkRenameProjects(ids, prefix), bulkDeleteProjects(ids), "
"mergeCustomers(sourceId, targetId)."
),
refs=["apps/web/src/lib/api.ts"],
),
],
),
]
def load_state() -> dict:
if PHASE17_STATE.exists():
return json.loads(PHASE17_STATE.read_text())
return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()}
def save_state(state: dict) -> None:
PHASE17_STATE.write_text(json.dumps(state, indent=2))
async def main() -> int:
log_section("🚀 Phase-17 Codegen-Run gestartet")
state = load_state()
for feature in FEATURES:
if feature.name in state.get("completed_features", []):
continue
state["current_feature"] = feature.name; save_state(state)
try:
success = await run_feature_v2(feature)
if success:
state.setdefault("completed_features", []).append(feature.name)
else:
state.setdefault("attempted_features", []).append(feature.name)
save_state(state)
except Exception as e:
log(f"{feature.name} crashed: {e}", level="ERROR")
state.setdefault("attempted_features", []).append(feature.name); save_state(state)
log_section("Phase-17 Run beendet")
log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))