diff --git a/.phase16-state.json b/.phase16-state.json index 26900f2..8438500 100644 --- a/.phase16-state.json +++ b/.phase16-state.json @@ -1,5 +1,8 @@ { "completed_features": [], - "current_feature": "pinned-customers", - "started_at": "2026-05-23T07:08:48.804883" + "current_feature": "smart-suggestions", + "started_at": "2026-05-23T07:08:48.804883", + "attempted_features": [ + "pinned-customers" + ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 6f43d67..8843667 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -1901,3 +1901,22 @@ 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. Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy +- `07:10:42` **INFO** Committed feature pinned-customers +- `07:10:42` **INFO** Pushed: rc=0 + +## Phase-3 Feature: smart-suggestions (2026-05-23 07:10:42) + +- `07:10:42` **INFO** Description: Auto-suggest Description basierend auf letzten Einträgen +- `07:10:42` **INFO** Generating apps/web/src/components/SuggestionInput.tsx (SuggestionInput-Component. Text-input mit dropdown suggestions unten. …) +- `07:11:09` **INFO** wrote 3255 chars in 26.9s (attempt 1) +- `07:11:09` **INFO** Generating apps/web/src/pages/TimeEntries.tsx (ERWEITERT — behalte alles. Description-Input nutzt jetzt SuggestionInp…) +- `07:12:51` **INFO** wrote 12829 chars in 101.6s (attempt 1) +- `07:12:51` **INFO** Running tsc --noEmit on api… +- `07:12:53` **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' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, 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' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 1e10fd1..70c32dd 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -102,4 +102,12 @@ export const savedViews = pgTable("saved_views", { entityType: text("entity_type").notNull(), config: text("config").notNull(), createdAt: timestamp("created_at").notNull().defaultNow() -}) \ No newline at end of file +}) +export const passwordResetTokens = pgTable("password_reset_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: timestamp("expires_at").notNull(), + usedAt: timestamp("used_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), +}) diff --git a/apps/web/src/components/SuggestionInput.tsx b/apps/web/src/components/SuggestionInput.tsx new file mode 100644 index 0000000..b6299ed --- /dev/null +++ b/apps/web/src/components/SuggestionInput.tsx @@ -0,0 +1,106 @@ +import React, { useState, useEffect, useRef } from 'react'; + +interface SuggestionInputProps { + value: string; + onChange: (val: string) => void; + suggestions: string[]; + placeholder?: string; + className?: string; +} + +export default function SuggestionInput({ + value, + onChange, + suggestions, + placeholder = 'Type to search...', + className = '', +}: SuggestionInputProps) { + const [isOpen, setIsOpen] = useState(false); + const [filtered, setFiltered] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); + + useEffect(() => { + if (!value) { + setFiltered([]); + return; + } + const lowerValue = value.toLowerCase(); + const matches = suggestions + .filter((s) => s.toLowerCase().startsWith(lowerValue)) + .filter((s) => s !== value) + .slice(0, 5); + + setFiltered(matches); + setSelectedIndex(-1); + }, [value, suggestions]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (selectedIndex >= 0 && filtered[selectedIndex]) { + e.preventDefault(); + selectSuggestion(filtered[selectedIndex]); + } + } else if (e.key === 'Escape') { + setIsOpen(false); + } + }; + + const selectSuggestion = (val: string) => { + onChange(val); + setIsOpen(false); + }; + + return ( +
+ { + onChange(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + /> + + {isOpen && filtered.length > 0 && ( +
    + {filtered.map((suggestion, index) => ( +
  • selectSuggestion(suggestion)} + className={`px-3 py-2 cursor-pointer text-sm transition-colors ${ + index === selectedIndex + ? 'bg-blue-100 text-blue-900' + : 'text-gray-700 hover:bg-gray-100' + }`} + > + {suggestion} +
  • + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/TimeEntries.tsx b/apps/web/src/pages/TimeEntries.tsx index 674901d..72df3f0 100644 --- a/apps/web/src/pages/TimeEntries.tsx +++ b/apps/web/src/pages/TimeEntries.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { api } from "../lib/api" import { EmptyState } from "../components/EmptyState" import { LoadingSpinner } from "../components/LoadingSpinner" +import { SuggestionInput } from "../components/SuggestionInput" import type { TimeEntryInsert, SavedView } from "@emberclone/shared" function renderSimpleMarkdown(text: string | null) { @@ -52,6 +53,11 @@ export default function TimeEntries() { queryFn: () => api.listSavedViews({ entityType: 'time-entries' }) }) + const descriptionSuggestions = useMemo(() => { + if (!entries) return [] + return Array.from(new Set(entries.map(e => e.description).filter(Boolean))).slice(0, 50) as string[] + }, [entries]) + const createMutation = useMutation({ mutationFn: (data: Partial) => api.createTimeEntry(data), onSuccess: () => { @@ -125,54 +131,7 @@ export default function TimeEntries() { window.location.href = `/api/time-entries/export.csv?${params.toString()}` } - const handleImportClick = () => { - fileInputRef.current?.click() - } - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - importMutation.mutate(file) - } - if (fileInputRef.current) fileInputRef.current.value = "" - } - - const toggleSelectAll = () => { - if (selectedIds.length === filteredEntries.length) { - setSelectedIds([]) - } else { - setSelectedIds(filteredEntries.map(e => e.id)) - } - } - - const toggleSelect = (id: string) => { - setSelectedIds(prev => - prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] - ) - } - - const handleSaveCurrentView = () => { - const name = prompt("Enter a name for this view:") - if (!name) return - saveViewMutation.mutate({ - name, - filters: { ...filters, entityType: 'time-entries' } - }) - } - - const applySavedView = (view: SavedView) => { - try { - const parsed = JSON.parse(view.filters) - setFilters({ - search: parsed.search || "", - from: parsed.from || "", - to: parsed.to || "" - }) - } catch (e) { - console.error("Failed to parse saved view filters", e) - } - } - + if (isLoading) return
if (isError) return
Error loading time entries.
return ( @@ -181,13 +140,7 @@ export default function TimeEntries() {

Time Entries

- +
+ + +
+
+ + setFormData(prev => ({ ...prev, description: val }))} + suggestions={descriptionSuggestions} + placeholder="What are you working on?" + /> +
+
+ + setFormData(prev => ({ ...prev, startTime: e.target.value }))} + required + /> +
+
+ + setFormData(prev => ({ ...prev, endTime: e.target.value }))} + required + /> +
+
+ +
+
+ +