From 0f78509d8a76784a360bbbd3c487f873857add0c Mon Sep 17 00:00:00 2001 From: marcelatjamie Date: Mon, 5 Jan 2026 14:44:49 +0200 Subject: [PATCH 1/2] feat: add habit editing and icon selection functionality - Introduced EditHabitDialog for updating existing habits with validation and feedback. - Implemented HabitIconPicker for selecting icons associated with habits. - Enhanced CreateHabitDialog to include icon selection and updated state management. - Updated habit management hooks to support icon updates during habit creation and editing. - Modified HabitOverlay and SettingsDialog to display habit icons and integrate editing features. --- stream/src/components/create-habit-dialog.tsx | 54 +++-- stream/src/components/edit-habit-dialog.tsx | 205 ++++++++++++++++++ stream/src/components/habit-icon-picker.tsx | 127 +++++++++++ stream/src/components/habit-overlay.tsx | 10 +- stream/src/components/settings-dialog.tsx | 83 ++++--- stream/src/hooks/use-habits.ts | 69 +++++- stream/src/ipc/habit-reader.ts | 114 +++++++++- 7 files changed, 603 insertions(+), 59 deletions(-) create mode 100644 stream/src/components/edit-habit-dialog.tsx create mode 100644 stream/src/components/habit-icon-picker.tsx diff --git a/stream/src/components/create-habit-dialog.tsx b/stream/src/components/create-habit-dialog.tsx index 0d4690a..0790159 100644 --- a/stream/src/components/create-habit-dialog.tsx +++ b/stream/src/components/create-habit-dialog.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; import { toast } from "sonner"; +import { HabitIconPicker } from "@/components/habit-icon-picker"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -21,7 +22,11 @@ import { SelectValue, } from "@/components/ui/select"; import { useCreateHabit } from "@/hooks/use-habits"; -import type { HabitPeriod } from "@/ipc/habit-reader"; +import { + DEFAULT_HABIT_ICON, + type HabitIcon, + type HabitPeriod, +} from "@/ipc/habit-reader"; interface CreateHabitDialogProps { open: boolean; @@ -35,6 +40,11 @@ export function CreateHabitDialog({ const [name, setName] = useState(""); const [targetCount, setTargetCount] = useState(1); const [period, setPeriod] = useState("weekly"); + const [icon, setIcon] = useState(DEFAULT_HABIT_ICON); + + const nameId = useId(); + const targetId = useId(); + const periodId = useId(); const { mutate: createHabit, isPending } = useCreateHabit(); @@ -52,13 +62,14 @@ export function CreateHabitDialog({ } createHabit( - { name: name.trim(), targetCount, period }, + { name: name.trim(), targetCount, period, icon }, { onSuccess: () => { toast.success(`Created habit: ${name.trim()}`); setName(""); setTargetCount(1); setPeriod("weekly"); + setIcon(DEFAULT_HABIT_ICON); onOpenChange(false); }, onError: (error) => { @@ -73,6 +84,7 @@ export function CreateHabitDialog({ setName(""); setTargetCount(1); setPeriod("weekly"); + setIcon(DEFAULT_HABIT_ICON); onOpenChange(false); } }; @@ -91,29 +103,37 @@ export function CreateHabitDialog({
- - setName(e.target.value)} - disabled={isPending} - autoFocus - /> + +
+ + setName(e.target.value)} + disabled={isPending} + autoFocus + className="flex-1" + /> +
- + setTargetCount( - Math.max(1, Number.parseInt(e.target.value) || 1), + Math.max(1, Number.parseInt(e.target.value, 10) || 1), ) } disabled={isPending} @@ -121,13 +141,13 @@ export function CreateHabitDialog({
- + setName(e.target.value)} + disabled={isPending} + autoFocus + className="flex-1" + /> +
+
+ +
+
+ + + setTargetCount( + Math.max(1, Number.parseInt(e.target.value, 10) || 1), + ) + } + disabled={isPending} + /> +
+ +
+ + +
+
+ +

+ {period === "daily" && + `Complete ${targetCount} time${targetCount > 1 ? "s" : ""} per day`} + {period === "weekly" && + `Complete ${targetCount} time${targetCount > 1 ? "s" : ""} per week`} + {period === "monthly" && + `Complete ${targetCount} time${targetCount > 1 ? "s" : ""} per month`} +

+
+ + + + + + + + + ); +} + +export default EditHabitDialog; diff --git a/stream/src/components/habit-icon-picker.tsx b/stream/src/components/habit-icon-picker.tsx new file mode 100644 index 0000000..4c2c8fb --- /dev/null +++ b/stream/src/components/habit-icon-picker.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { + BarbellIcon, + BicycleIcon, + BookOpenIcon, + BrainIcon, + CameraIcon, + CheckSquareIcon, + ClockIcon, + CoffeeIcon, + DropIcon, + FireIcon, + HeartIcon, + type IconProps, + LightningIcon, + MoonIcon, + MusicNoteIcon, + PencilIcon, + StarIcon, + SunIcon, + TargetIcon, + TimerIcon, + TreeIcon, +} from "@phosphor-icons/react"; +import type { ComponentType } from "react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + DEFAULT_HABIT_ICON, + HABIT_ICONS, + type HabitIcon, +} from "@/ipc/habit-reader"; + +/** + * Map icon names to their Phosphor components + */ +const iconComponents: Record> = { + Barbell: BarbellIcon, + Bicycle: BicycleIcon, + Heart: HeartIcon, + Lightning: LightningIcon, + Fire: FireIcon, + Timer: TimerIcon, + BookOpen: BookOpenIcon, + Pencil: PencilIcon, + Target: TargetIcon, + Star: StarIcon, + CheckSquare: CheckSquareIcon, + Clock: ClockIcon, + Coffee: CoffeeIcon, + Drop: DropIcon, + Moon: MoonIcon, + Sun: SunIcon, + Tree: TreeIcon, + Brain: BrainIcon, + MusicNote: MusicNoteIcon, + Camera: CameraIcon, +}; + +interface HabitIconPickerProps { + value?: HabitIcon; + onChange: (icon: HabitIcon) => void; + disabled?: boolean; +} + +/** + * Get the icon component for a habit icon name + */ +export function getHabitIconComponent( + icon?: HabitIcon, +): ComponentType { + return iconComponents[icon || DEFAULT_HABIT_ICON]; +} + +export function HabitIconPicker({ + value, + onChange, + disabled, +}: HabitIconPickerProps) { + const selectedIcon = value || DEFAULT_HABIT_ICON; + const SelectedIconComponent = iconComponents[selectedIcon]; + + return ( + + + + + +
+ {HABIT_ICONS.map((iconName) => { + const IconComponent = iconComponents[iconName]; + const isSelected = iconName === selectedIcon; + + return ( + + ); + })} +
+
+
+ ); +} + +export default HabitIconPicker; diff --git a/stream/src/components/habit-overlay.tsx b/stream/src/components/habit-overlay.tsx index c75539b..0fcc9c1 100644 --- a/stream/src/components/habit-overlay.tsx +++ b/stream/src/components/habit-overlay.tsx @@ -7,6 +7,7 @@ import { TargetIcon, } from "@phosphor-icons/react"; import { useState } from "react"; +import { getHabitIconComponent } from "@/components/habit-icon-picker"; import { Accordion, AccordionContent, @@ -18,10 +19,10 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { useHabits, useUpdateCompletion } from "@/hooks/use-habits"; import { - type Habit, getCompletionForDate, getCompletionsForPeriod, getPeriodLabel, + type Habit, } from "@/ipc/habit-reader"; interface HabitOverlayProps { @@ -44,6 +45,8 @@ function HabitRow({ habit, date }: HabitRowProps) { const isTargetMet = completed >= target; const periodLabel = getPeriodLabel(habit.period); + const HabitIcon = getHabitIconComponent(habit.icon); + const handleIncrement = () => { updateCompletion({ habitId: habit.id, date, action: "increment" }); }; @@ -59,12 +62,9 @@ function HabitRow({ habit, date }: HabitRowProps) { {isTargetMet ? ( ) : ( - + )} {habit.name} - - {habit.period} -
diff --git a/stream/src/components/settings-dialog.tsx b/stream/src/components/settings-dialog.tsx index 69d5c47..fc17408 100644 --- a/stream/src/components/settings-dialog.tsx +++ b/stream/src/components/settings-dialog.tsx @@ -7,6 +7,7 @@ import { EyeSlashIcon, FolderOpenIcon, GitBranchIcon, + PencilIcon, PlusIcon, SparkleIcon, TargetIcon, @@ -17,6 +18,8 @@ import { getVersion } from "@tauri-apps/api/app"; import { useEffect, useId, useState } from "react"; import { toast } from "sonner"; import { CreateHabitDialog } from "@/components/create-habit-dialog"; +import { EditHabitDialog } from "@/components/edit-habit-dialog"; +import { getHabitIconComponent } from "@/components/habit-icon-picker"; import RepoConnector from "@/components/repo-connector"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -210,6 +213,7 @@ function AISettingsCard() { function HabitsCard() { const [createHabitOpen, setCreateHabitOpen] = useState(false); + const [habitToEdit, setHabitToEdit] = useState(null); const [habitToDelete, setHabitToDelete] = useState(null); const { data: habits = [], isLoading } = useHabits(); @@ -276,36 +280,55 @@ function HabitsCard() {
) : (
- {habits.map((habit) => ( -
-
-
- {habit.name} - { + const HabitIcon = getHabitIconComponent(habit.icon); + return ( +
+
+ +
+
+ + {habit.name} + + + {habit.period} + +
+ + {getPeriodText(habit)} + +
+
+
+ +
- - {getPeriodText(habit)} -
- -
- ))} + ); + })}
)} @@ -316,6 +339,12 @@ function HabitsCard() { onOpenChange={setCreateHabitOpen} /> + !open && setHabitToEdit(null)} + /> + setHabitToDelete(null)} diff --git a/stream/src/hooks/use-habits.ts b/stream/src/hooks/use-habits.ts index 7b963ac..ec4f098 100644 --- a/stream/src/hooks/use-habits.ts +++ b/stream/src/hooks/use-habits.ts @@ -1,12 +1,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - type Habit, - type HabitPeriod, createHabit, decrementCompletion, deleteHabit, getAllHabits, + type Habit, + type HabitIcon, + type HabitPeriod, incrementCompletion, + updateHabit, } from "@/ipc/habit-reader"; /** @@ -39,11 +41,13 @@ export function useCreateHabit() { name, targetCount, period, + icon, }: { name: string; targetCount: number; period: HabitPeriod; - }) => createHabit(name, targetCount, period), + icon?: HabitIcon; + }) => createHabit(name, targetCount, period, icon), onSuccess: (newHabit) => { // Optimistically add the new habit to the cache queryClient.setQueryData(habitKeys.list(), (old) => { @@ -95,6 +99,65 @@ export function useDeleteHabit() { }); } +/** + * Hook to update an existing habit + */ +export function useUpdateHabit() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + updates, + }: { + id: string; + updates: { + name?: string; + targetCount?: number; + period?: HabitPeriod; + icon?: HabitIcon; + }; + }) => updateHabit(id, updates), + onMutate: async ({ id, updates }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: habitKeys.list() }); + + // Snapshot the previous value + const previousHabits = queryClient.getQueryData( + habitKeys.list(), + ); + + // Optimistically update the habit + queryClient.setQueryData(habitKeys.list(), (old) => { + if (!old) return []; + return old.map((habit) => { + if (habit.id !== id) return habit; + return { + ...habit, + ...(updates.name !== undefined && { name: updates.name.trim() }), + ...(updates.targetCount !== undefined && { + targetCount: updates.targetCount, + }), + ...(updates.period !== undefined && { period: updates.period }), + ...(updates.icon !== undefined && { icon: updates.icon }), + }; + }); + }); + + return { previousHabits }; + }, + onError: (_err, _vars, context) => { + // Roll back on error + if (context?.previousHabits) { + queryClient.setQueryData(habitKeys.list(), context.previousHabits); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: habitKeys.list() }); + }, + }); +} + /** * Hook to increment/decrement habit completions */ diff --git a/stream/src/ipc/habit-reader.ts b/stream/src/ipc/habit-reader.ts index ccfe240..854cc77 100644 --- a/stream/src/ipc/habit-reader.ts +++ b/stream/src/ipc/habit-reader.ts @@ -5,6 +5,62 @@ import { Store } from "@tauri-apps/plugin-store"; */ export type HabitPeriod = "daily" | "weekly" | "monthly"; +/** + * Available icons for habits + */ +export type HabitIcon = + | "Barbell" + | "Bicycle" + | "Heart" + | "Lightning" + | "Fire" + | "Timer" + | "BookOpen" + | "Pencil" + | "Target" + | "Star" + | "CheckSquare" + | "Clock" + | "Coffee" + | "Drop" + | "Moon" + | "Sun" + | "Tree" + | "Brain" + | "MusicNote" + | "Camera"; + +/** + * Default icon for habits when none is specified + */ +export const DEFAULT_HABIT_ICON: HabitIcon = "Target"; + +/** + * List of all available habit icons + */ +export const HABIT_ICONS: HabitIcon[] = [ + "Target", + "Barbell", + "Bicycle", + "Heart", + "Lightning", + "Fire", + "Timer", + "BookOpen", + "Pencil", + "Star", + "CheckSquare", + "Clock", + "Coffee", + "Drop", + "Moon", + "Sun", + "Tree", + "Brain", + "MusicNote", + "Camera", +]; + /** * Represents a habit with its configuration and completion history */ @@ -17,19 +73,14 @@ export interface Habit { targetCount: number; /** The tracking period */ period: HabitPeriod; + /** Icon for the habit (defaults to Target if not set) */ + icon?: HabitIcon; /** Creation timestamp in milliseconds */ createdAt: number; /** Completion counts by date (YYYY-MM-DD -> count) */ completions: Record; } -/** - * Structure of the habits.json store - */ -interface HabitData { - habits: Habit[]; -} - // Store instance for habits let store: Store | null = null; @@ -187,6 +238,7 @@ export async function createHabit( name: string, targetCount: number, period: HabitPeriod, + icon?: HabitIcon, ): Promise { try { const s = await getStore(); @@ -197,6 +249,7 @@ export async function createHabit( name: name.trim(), targetCount, period, + icon, createdAt: Date.now(), completions: {}, }; @@ -234,6 +287,53 @@ export async function deleteHabit(id: string): Promise { } } +/** + * Update an existing habit + */ +export async function updateHabit( + id: string, + updates: { + name?: string; + targetCount?: number; + period?: HabitPeriod; + icon?: HabitIcon; + }, +): Promise { + try { + const s = await getStore(); + const habits = (await s.get(HABITS_KEY)) || []; + + const habitIndex = habits.findIndex((h) => h.id === id); + if (habitIndex === -1) { + throw new Error("Habit not found"); + } + + const habit = habits[habitIndex]; + + // Apply updates + if (updates.name !== undefined) { + habit.name = updates.name.trim(); + } + if (updates.targetCount !== undefined) { + habit.targetCount = updates.targetCount; + } + if (updates.period !== undefined) { + habit.period = updates.period; + } + if (updates.icon !== undefined) { + habit.icon = updates.icon; + } + + await s.set(HABITS_KEY, habits); + await s.save(); + + return habit; + } catch (error) { + console.error("Error updating habit:", error); + throw new Error("Failed to update habit"); + } +} + /** * Increment the completion count for a habit on a specific date */ From ce5312acbab0ec999d6c1e702ba71e321d3314d3 Mon Sep 17 00:00:00 2001 From: marcelatjamie Date: Mon, 5 Jan 2026 14:45:13 +0200 Subject: [PATCH 2/2] taur version --- stream/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream/src-tauri/tauri.conf.json b/stream/src-tauri/tauri.conf.json index 94d992d..bbb7987 100644 --- a/stream/src-tauri/tauri.conf.json +++ b/stream/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "stream", - "version": "0.2.4", + "version": "0.2.5", "identifier": "com.marcelmarais.stream", "build": { "beforeDevCommand": "pnpm dev",