add habit tracking page with grid layout\
\ - Add HabitGrid component with a responsive calendar-style layout\\ - Support variable habit durations with period-based cell rendering\ - Add skeleton loading states for better UX during data fetching - Add CounterHabitButton component for cells with increment/decrement interactions\ - Create HabitCellContextMenu for period-based actions (reset, set values, fail) - Add HabitCreationForm for creating new habits\ - Add HabitActionMenu component for additional operations on habits (archive, delete, edit)\ - Add SetValueDialog component for editing the value of a cell
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import type { HabitWithCompletions } from '$lib/context/habits.svelte';
|
||||
import HabitGrid from './HabitGrid.svelte';
|
||||
import HabitCreationForm from './HabitCreationForm.svelte';
|
||||
import { setHabitService } from '$lib/context/habits.svelte';
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Calendar } from '$lib/components/ui/calendar';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '$lib/components/ui/alert';
|
||||
|
||||
// Icons
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import CalendarIcon from '@lucide/svelte/icons/calendar';
|
||||
import RefreshCcw from '@lucide/svelte/icons/refresh-ccw';
|
||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
import EyeOff from '@lucide/svelte/icons/eye-off';
|
||||
|
||||
import { today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let showCreateDialog = $state(false);
|
||||
|
||||
// Date selection state
|
||||
let selectedDate = $state(today(getLocalTimeZone()));
|
||||
let showCalendar = $state(false);
|
||||
|
||||
// Habit visibility toggle
|
||||
let showInactiveHabits = $state(false);
|
||||
|
||||
// Create habit service instance - subscriptions will handle all data loading
|
||||
const habitService = setHabitService('default-user');
|
||||
|
||||
// Simple loading state - just show skeleton briefly while subscriptions initialize
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Hide loading after a brief moment to let subscriptions initialize
|
||||
onMount(() => {
|
||||
setTimeout(() => {
|
||||
loading = false;
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full container mx-auto p-6 space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-foreground">Habits</h1>
|
||||
<p class="text-muted-foreground">Track your daily habits and build better routines</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Date Selector -->
|
||||
<Popover.Root bind:open={showCalendar}>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-48 justify-start text-left font-normal"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
type="single"
|
||||
bind:value={selectedDate}
|
||||
onValueChange={() => showCalendar = false}
|
||||
class="rounded-md border shadow-sm"
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<!-- New Habit Button -->
|
||||
<Dialog.Root bind:open={showCreateDialog}>
|
||||
<Dialog.Trigger>
|
||||
<Button class="gap-2">
|
||||
<Plus class="h-4 w-4" />
|
||||
New Habit
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<HabitCreationForm
|
||||
onHabitCreated={() => {
|
||||
showCreateDialog = false;
|
||||
}}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Main Content -->
|
||||
{#if loading}
|
||||
<Card.Root class="min-h-96">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5" />
|
||||
Habit Progress
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
Track your habits day by day. Click to mark as complete or update values.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<!-- Use HabitGrid component with skeleton mode -->
|
||||
<HabitGrid bind:centerDate={selectedDate} skeleton={true} bind:showInactiveHabits />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if error}
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="ml-3"
|
||||
onclick={() => window.location.reload()}
|
||||
>
|
||||
<RefreshCcw class="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else if habitService.habits.length === 0}
|
||||
<Card.Root>
|
||||
<Card.Content class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-muted mb-4">
|
||||
<TrendingUp class="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">No habits yet</h3>
|
||||
<p class="text-muted-foreground mb-6 max-w-sm">
|
||||
Start building better routines by creating your first habit. Track daily activities and watch your progress grow.
|
||||
</p>
|
||||
<Button onclick={() => showCreateDialog = true} class="gap-2">
|
||||
<Plus class="h-4 w-4" />
|
||||
Create Your First Habit
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<!-- Habit Grid -->
|
||||
<Card.Root class="min-h-96">
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5" />
|
||||
Habit Progress
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
Track your habits day by day. Click to mark as complete or update values.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => showInactiveHabits = !showInactiveHabits}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{#if showInactiveHabits}
|
||||
<EyeOff class="h-4 w-4" />
|
||||
Hide Inactive
|
||||
{:else}
|
||||
<Eye class="h-4 w-4" />
|
||||
Show Inactive
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<HabitGrid bind:centerDate={selectedDate} bind:showInactiveHabits />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import { Confetti } from 'svelte-confetti';
|
||||
import NumberFlow from '@number-flow/svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import { getHabitService } from '$lib/context/habits.svelte';
|
||||
|
||||
let {
|
||||
habitId,
|
||||
target = 1,
|
||||
length = 1,
|
||||
unit = '',
|
||||
increment = 1,
|
||||
count = 0,
|
||||
date = new Date().toISOString().split('T')[0],
|
||||
direction = 'end',
|
||||
isToday = false,
|
||||
isFailed = false,
|
||||
isTruncatedLeft = false,
|
||||
isTruncatedRight = false,
|
||||
class: className = ''
|
||||
}: {
|
||||
update: Function;
|
||||
habitId: string;
|
||||
target?: number;
|
||||
length?: number;
|
||||
unit?: string;
|
||||
increment?: number;
|
||||
count?: number;
|
||||
date?: string;
|
||||
direction?: 'start' | 'end';
|
||||
isToday?: boolean;
|
||||
isFailed?: boolean;
|
||||
isTruncatedLeft?: boolean;
|
||||
isTruncatedRight?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const habitService = getHabitService();
|
||||
|
||||
function getDisplayUnit(unit: string): { text: string; isShort: boolean } {
|
||||
const trimmed = unit.trim().toLowerCase();
|
||||
|
||||
// If no unit, return empty
|
||||
if (!trimmed) return { text: '', isShort: true };
|
||||
|
||||
// Common abbreviations for longer units
|
||||
const abbreviations: Record<string, string> = {
|
||||
'minutes': 'min',
|
||||
'glasses': 'gl',
|
||||
'pages': 'pg',
|
||||
'exercises': 'ex',
|
||||
'repetitions': 'rep',
|
||||
'kilometers': 'km',
|
||||
'calories': 'cal',
|
||||
'pounds': 'lbs',
|
||||
'kilograms': 'kg'
|
||||
};
|
||||
|
||||
// Use abbreviation if available
|
||||
const displayText = abbreviations[trimmed] || trimmed;
|
||||
|
||||
// Determine layout based on length
|
||||
// 1 letter: right side
|
||||
// 2-3 letters: bottom
|
||||
// 4+ letters: take first letter and put on right
|
||||
if (displayText.length === 1) {
|
||||
return { text: displayText, isShort: true };
|
||||
} else if (displayText.length <= 3) {
|
||||
return { text: displayText, isShort: false };
|
||||
} else {
|
||||
return { text: displayText.charAt(0), isShort: true };
|
||||
}
|
||||
}
|
||||
|
||||
const displayUnit = $derived(getDisplayUnit(unit));
|
||||
|
||||
let checked = $derived(count >= target);
|
||||
let showConfetti = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let hideNumber = $state(count === 0);
|
||||
let animatingToZero = $state(false);
|
||||
|
||||
// Track when we're animating to zero to delay hiding
|
||||
$effect(() => {
|
||||
if (count === 0 && !hideNumber) {
|
||||
// We just went to 0, show animation then hide
|
||||
animatingToZero = true;
|
||||
setTimeout(() => {
|
||||
if (count === 0) { // Only hide if still 0
|
||||
hideNumber = true;
|
||||
animatingToZero = false;
|
||||
}
|
||||
}, 600);
|
||||
} else if (count > 0) {
|
||||
// Not zero anymore, show immediately
|
||||
hideNumber = false;
|
||||
animatingToZero = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function onClick(e: MouseEvent) {
|
||||
if (target == 1) {
|
||||
if (count == 0) {
|
||||
count = 1;
|
||||
habitService.playCompletionSound();
|
||||
showConfetti = true;
|
||||
setTimeout(() => showConfetti = false, 1000);
|
||||
} else {
|
||||
count = 0;
|
||||
}
|
||||
} else {
|
||||
const oldCount = count;
|
||||
if (e.shiftKey) {
|
||||
// Decrement but don't go below 0 - animate to 0 if needed
|
||||
const newCount = count - increment;
|
||||
count = newCount < 0 ? 0 : newCount;
|
||||
} else {
|
||||
count = count + increment;
|
||||
}
|
||||
|
||||
// Trigger confetti when we reach or exceed target
|
||||
if (oldCount < target && count >= target) {
|
||||
habitService.playCompletionSound();
|
||||
showConfetti = true;
|
||||
setTimeout(() => showConfetti = false, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await habitService.setCompletionValue(habitId, count, date);
|
||||
} catch (error) {
|
||||
// Error logging is handled in the service
|
||||
console.error('Failed to update habit completion:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'z-10 flex h-12 w-full shrink-0 items-center justify-center shadow-sm select-none hover:cursor-pointer transition-all duration-200',
|
||||
'animate-in fade-in-5 duration-300',
|
||||
direction === 'end' ? 'slide-in-from-right-12' : 'slide-in-from-left-12',
|
||||
checked
|
||||
? 'bg-primary text-primary-foreground border border-primary hover:bg-primary/90'
|
||||
: 'bg-card border border-border text-foreground hover:bg-muted/50',
|
||||
isToday && !checked && 'ring-2 ring-ring bg-accent',
|
||||
isFailed && !checked && 'bg-destructive/10 border-destructive/30',
|
||||
// Truncation styling - missing borders and rounded corners
|
||||
isTruncatedLeft ? 'border-l-0 rounded-l-none' : 'rounded-l-lg',
|
||||
isTruncatedRight ? 'border-r-0 rounded-r-none' : 'rounded-r-lg',
|
||||
// Default rounded corners when no truncation
|
||||
!isTruncatedLeft && !isTruncatedRight && 'rounded-lg',
|
||||
className
|
||||
)}
|
||||
style="grid-column: span {length};"
|
||||
onclick={onClick}
|
||||
>
|
||||
{#if showConfetti}
|
||||
<div class="z-0">
|
||||
<Confetti
|
||||
y={[-0.4, 0.4]}
|
||||
x={[-0.4 * length * 0.6, 0.4 * length * 0.6]}
|
||||
size={4}
|
||||
colorArray={['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b']}
|
||||
fallDistance="0px"
|
||||
duration={1000}
|
||||
amount={50 * (length ** 0.7)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if target > 1}
|
||||
{#if displayUnit.text && displayUnit.isShort}
|
||||
<!-- Single letter unit: horizontal layout -->
|
||||
<div class="flex items-baseline gap-1">
|
||||
<NumberFlow value={count} class={cn("font-semibold text-lg", hideNumber && "invisible")} />
|
||||
{#if count > 0}
|
||||
<span class="text-[11px] opacity-75 font-medium uppercase">{displayUnit.text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if displayUnit.text && !displayUnit.isShort}
|
||||
<!-- 2-3 letter unit: vertical layout -->
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<NumberFlow value={count} class={cn("font-semibold text-lg leading-tight", hideNumber && "invisible")} />
|
||||
{#if count > 0}
|
||||
<span class="text-[11px] opacity-75 font-medium lowercase leading-none -mt-1">{displayUnit.text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- No unit: just the number -->
|
||||
<NumberFlow value={count} class={cn("font-semibold", hideNumber && "invisible")} />
|
||||
{/if}
|
||||
{:else if checked}
|
||||
<Check class="text-primary-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { EllipsisVertical, Pencil, Trash2 } from '@lucide/svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { getHabitService } from '$lib/context/habits.svelte';
|
||||
|
||||
const habitService = getHabitService();
|
||||
|
||||
let { habitId } = $props();
|
||||
|
||||
// Get the current habit reactively from the service
|
||||
const currentHabit = $derived(habitService.habits.find(h => h.id === habitId));
|
||||
|
||||
let deleteHabitDialog = $state(false);
|
||||
let editHabitDialog = $state(false);
|
||||
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<EllipsisVertical class="h-4 w-4 hover:cursor-pointer" /></DropdownMenu.Trigger
|
||||
>
|
||||
<DropdownMenu.Content class="w-56">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Habit options</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.CheckboxItem checked={currentHabit?.active || false} onclick={() => currentHabit && habitService.updateHabit(currentHabit.id, { active: !currentHabit.active })}>
|
||||
Active
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.Item onclick={() => (editHabitDialog = true)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => (deleteHabitDialog = true)}>
|
||||
<div class="flex gap-2">
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
<p>Delete</p>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Edit Habit Dialog -->
|
||||
<Dialog.Root bind:open={editHabitDialog}>
|
||||
<Dialog.Content class="sm:max-w-110">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit profile</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Make changes to your profile here. Click save when you're done.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="name" class="text-right">Name</Label>
|
||||
<Input id="name" value="Pedro Duarte" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="username" class="text-right">Username</Label>
|
||||
<Input id="username" value="@peduarte" class="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Delete Habit Dialog -->
|
||||
<AlertDialog.Root bind:open={deleteHabitDialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Are you sure?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This action cannot be undone. This will permanently delete the habit "{currentHabit?.name || 'Unknown'}"
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
|
||||
<Button onclick={() => currentHabit && habitService.deleteHabit(currentHabit.id)} variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import { RotateCcw, SquarePen, CircleX } from '@lucide/svelte';
|
||||
import SetValueDialog from './SetValueDialog.svelte';
|
||||
import type { HabitWithCompletions } from '$lib/context/habits.svelte';
|
||||
|
||||
let {
|
||||
habit,
|
||||
cell,
|
||||
children,
|
||||
onResetPeriod,
|
||||
onSetValue,
|
||||
onToggleFailed
|
||||
}: {
|
||||
habit: HabitWithCompletions;
|
||||
cell: {
|
||||
value: number;
|
||||
date: string;
|
||||
span: number;
|
||||
gridColumn: number;
|
||||
isFailed: boolean;
|
||||
isEmpty: boolean;
|
||||
periodDates: string[];
|
||||
};
|
||||
children: import('svelte').Snippet;
|
||||
onResetPeriod: () => Promise<void>;
|
||||
onSetValue: (totalValue: number, dailyValues?: Record<string, number>) => Promise<void>;
|
||||
onToggleFailed: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let showSetValueDialog = $state(false);
|
||||
|
||||
async function handleResetPeriod() {
|
||||
await onResetPeriod();
|
||||
}
|
||||
|
||||
async function handleSetValue(totalValue: number, dailyValues?: Record<string, number>) {
|
||||
await onSetValue(totalValue, dailyValues);
|
||||
showSetValueDialog = false;
|
||||
}
|
||||
|
||||
async function handleToggleFailed() {
|
||||
await onToggleFailed();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger class="w-full">
|
||||
{@render children()}
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content class="w-48">
|
||||
<ContextMenu.Item onclick={handleResetPeriod} class="gap-2">
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
Reset period
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item onclick={() => showSetValueDialog = true} class="gap-2">
|
||||
<SquarePen class="h-4 w-4" />
|
||||
Set value...
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Separator />
|
||||
|
||||
<ContextMenu.Item onclick={handleToggleFailed} class="gap-2">
|
||||
<CircleX class="h-4 w-4" />
|
||||
{cell.isFailed ? 'Mark as unfailed' : 'Mark as failed'}
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<SetValueDialog
|
||||
bind:open={showSetValueDialog}
|
||||
{habit}
|
||||
{cell}
|
||||
onSetValue={handleSetValue}
|
||||
/>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { ChevronDown, ChevronUp } from '@lucide/svelte';
|
||||
import { getHabitService } from '$lib/context/habits.svelte';
|
||||
|
||||
let { onHabitCreated }: { onHabitCreated?: () => Promise<void> | void } = $props();
|
||||
|
||||
const habitService = getHabitService();
|
||||
|
||||
let showAdvancedOptions = $state(false);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// Form fields
|
||||
let name = $state('');
|
||||
let target = $state(1);
|
||||
let duration = $state(1);
|
||||
let unit = $state('');
|
||||
let increment = $state(1);
|
||||
let startDate = $state(new Date().toISOString().split('T')[0]);
|
||||
let active = $state(true);
|
||||
|
||||
|
||||
async function handleSubmit() {
|
||||
if (isSubmitting || !name.trim()) return;
|
||||
|
||||
try {
|
||||
isSubmitting = true;
|
||||
|
||||
await habitService.createHabit({
|
||||
name,
|
||||
target,
|
||||
duration,
|
||||
increment,
|
||||
unit,
|
||||
startDate: (() => {
|
||||
// Create date in local timezone to avoid UTC conversion issues
|
||||
const [year, month, day] = startDate.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
})(),
|
||||
active
|
||||
})
|
||||
|
||||
// Reset form
|
||||
name = '';
|
||||
target = 1;
|
||||
duration = 1;
|
||||
unit = '';
|
||||
increment = 1;
|
||||
startDate = new Date().toISOString().split('T')[0];
|
||||
active = true;
|
||||
showAdvancedOptions = false;
|
||||
|
||||
// Notify parent
|
||||
onHabitCreated?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to create habit:', error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create a new habit</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Add a new habit to track. Set your target and customize the tracking period.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Habit Name -->
|
||||
<div class="space-y-2">
|
||||
<Label for="name" class="text-sm font-medium">
|
||||
Habit Name <span class="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
bind:value={name}
|
||||
placeholder="e.g. Drink 8 glasses of water, Exercise for 30 minutes"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Give your habit a clear, specific name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Target & Duration Row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="target" class="text-sm font-medium">Target</Label>
|
||||
<Input
|
||||
id="target"
|
||||
type="number"
|
||||
bind:value={target}
|
||||
min="1"
|
||||
max="999"
|
||||
disabled={isSubmitting}
|
||||
placeholder="1"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Daily goal to reach
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="duration" class="text-sm font-medium">Period (days)</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
bind:value={duration}
|
||||
min="1"
|
||||
max="30"
|
||||
disabled={isSubmitting}
|
||||
placeholder="1"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
How many days per period
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options -->
|
||||
<div class="space-y-4">
|
||||
<Collapsible.Root bind:open={showAdvancedOptions}>
|
||||
<Collapsible.Trigger class="flex items-center gap-2 py-2 text-sm font-medium hover:text-primary transition-colors">
|
||||
{#if showAdvancedOptions}
|
||||
<ChevronUp class="h-4 w-4" />
|
||||
{:else}
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
{/if}
|
||||
Advanced Settings
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="space-y-4 pt-2">
|
||||
<!-- Increment & Unit Row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="increment" class="text-sm font-medium">Increment</Label>
|
||||
<Input
|
||||
id="increment"
|
||||
type="number"
|
||||
bind:value={increment}
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
max="100"
|
||||
disabled={isSubmitting}
|
||||
placeholder="1"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Step size for tracking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="unit" class="text-sm font-medium">Unit</Label>
|
||||
<Input
|
||||
id="unit"
|
||||
bind:value={unit}
|
||||
placeholder="e.g. glasses, minutes, pages"
|
||||
disabled={isSubmitting}
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Unit of measurement
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Date & Active Row -->
|
||||
<div class="grid grid-cols-2 gap-4 items-start">
|
||||
<div class="space-y-2">
|
||||
<Label for="startDate" class="text-sm font-medium">Start Date</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="date"
|
||||
bind:value={startDate}
|
||||
disabled={isSubmitting}
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
When to begin tracking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">Status</Label>
|
||||
<div class="flex items-center space-x-2 h-10">
|
||||
<Checkbox
|
||||
id="active"
|
||||
bind:checked={active}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label for="active" class="text-sm font-normal cursor-pointer">
|
||||
Start tracking immediately
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Enable habit tracking
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="flex gap-2">
|
||||
<Dialog.Close>
|
||||
<Button variant="outline" disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
class="gap-2"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
Creating...
|
||||
{:else}
|
||||
Create Habit
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
@@ -0,0 +1,366 @@
|
||||
<script lang="ts">
|
||||
import * as DateStrip from '$lib/components/ui/date-strip/index';
|
||||
import { setDateStripContext } from '$lib/components/ui/date-strip/ctx';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronsLeft from '@lucide/svelte/icons/chevrons-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ChevronsRight from '@lucide/svelte/icons/chevrons-right';
|
||||
import { today, CalendarDate, getLocalTimeZone } from '@internationalized/date';
|
||||
import CounterHabitButton from './CounterHabitButton.svelte';
|
||||
import type { HabitCell } from '$lib/context/habits.svelte';
|
||||
import type { Habit } from '$lib/triplit/schema';
|
||||
import { getHabitService } from '$lib/context/habits.svelte';
|
||||
import HabitActionMenu from './HabitActionMenu.svelte';
|
||||
import HabitCellContextMenu from './HabitCellContextMenu.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
let {
|
||||
centerDate = $bindable(today(getLocalTimeZone())),
|
||||
skeleton = false,
|
||||
showInactiveHabits = $bindable(false)
|
||||
}: {
|
||||
centerDate?: CalendarDate;
|
||||
skeleton?: boolean;
|
||||
showInactiveHabits?: boolean;
|
||||
} = $props();
|
||||
|
||||
const habitService = getHabitService();
|
||||
|
||||
let containerWidth = $state(0);
|
||||
|
||||
// Filter habits based on toggle state
|
||||
const displayedHabits = $derived(
|
||||
showInactiveHabits
|
||||
? habitService.habits
|
||||
: habitService.habits.filter(h => h.active)
|
||||
);
|
||||
|
||||
let numCellsInRow = $derived.by(() => {
|
||||
if (containerWidth === 0) return 7; // Default while measuring
|
||||
// Account for habit name column (120px) and navigation columns (120px each), plus gaps
|
||||
const availableWidth = containerWidth - 360 - 32; // 360px for fixed columns + 32px for padding
|
||||
const cellWidth = 48; // Fixed cell width to match DateStrip items (w-12)
|
||||
const gapWidth = 8; // 2 * 4px gap between columns
|
||||
const cellWithGap = cellWidth + gapWidth;
|
||||
const calculatedCells = Math.floor(availableWidth / cellWithGap);
|
||||
return Math.max(Math.min(calculatedCells, 21), 3); // Between 3 and 21 columns
|
||||
});
|
||||
// Initialize grid start date to center the provided centerDate
|
||||
let gridStartDate = $state(centerDate);
|
||||
|
||||
// Update grid start when centerDate prop changes
|
||||
$effect(() => {
|
||||
if (numCellsInRow > 0) {
|
||||
// Calculate offset so centerDate appears in the center of the grid
|
||||
const centerOffset = Math.floor(numCellsInRow / 2);
|
||||
gridStartDate = centerDate.subtract({ days: centerOffset });
|
||||
}
|
||||
});
|
||||
|
||||
const displayedDates = $derived.by(() =>
|
||||
Array.from({ length: numCellsInRow }, (_, i) => gridStartDate.add({ days: i }))
|
||||
);
|
||||
|
||||
|
||||
|
||||
setDateStripContext({
|
||||
selectedValue: () => habitService.todaysDate,
|
||||
onSelect: (date) => {
|
||||
const calendarDate = date instanceof CalendarDate ? date : new CalendarDate(date.year, date.month, date.day);
|
||||
gridStartDate = calendarDate.add({ days: -Math.floor(numCellsInRow / 2) });
|
||||
centerDate = calendarDate;
|
||||
},
|
||||
isDateDisabled: () => false,
|
||||
direction: () => 'end'
|
||||
});
|
||||
|
||||
|
||||
async function updateCompletion(habitId: string, value: number, date: string): Promise<void> {
|
||||
await habitService.setCompletionValue(habitId, value, date);
|
||||
}
|
||||
|
||||
async function navigate(days: number) {
|
||||
const newGridStartDate = gridStartDate.add({ days });
|
||||
const newDisplayedDates = Array.from({ length: numCellsInRow }, (_, i) => newGridStartDate.add({ days: i }));
|
||||
const viewStart = newDisplayedDates[0];
|
||||
const viewEnd = newDisplayedDates[newDisplayedDates.length - 1];
|
||||
|
||||
gridStartDate = newGridStartDate;
|
||||
|
||||
// Update the center date to sync with parent
|
||||
const centerOffset = Math.floor(numCellsInRow / 2);
|
||||
centerDate = newGridStartDate.add({ days: centerOffset });
|
||||
|
||||
// Check and load more data if needed
|
||||
await habitService.checkAndLoadMoreData(viewStart, viewEnd);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowLeft') navigate(-1);
|
||||
else if (event.key === 'ArrowRight') navigate(1);
|
||||
}
|
||||
|
||||
function createHabitCells(habit: Habit, displayedDates: CalendarDate[], numCellsInRow: number): HabitCell[] {
|
||||
const duration = habit.duration || 1;
|
||||
const cells: HabitCell[] = [];
|
||||
const referenceDate = new CalendarDate(2000, 1, 1);
|
||||
|
||||
if (displayedDates.length === 0) {
|
||||
return cells;
|
||||
}
|
||||
|
||||
const dailyCompletions = habitService.getCompletionsForHabit(habit.id);
|
||||
|
||||
const searchStart = displayedDates[0].subtract({ days: duration });
|
||||
const searchStartTime = searchStart.toDate('UTC').getTime();
|
||||
const referenceTime = referenceDate.toDate('UTC').getTime();
|
||||
const daysSinceRef = Math.floor((searchStartTime - referenceTime) / (24 * 60 * 60 * 1000));
|
||||
const remainder = daysSinceRef % duration;
|
||||
|
||||
let periodStart = remainder === 0 ? searchStart : searchStart.add({ days: duration - remainder });
|
||||
|
||||
while (periodStart.compare(displayedDates[displayedDates.length - 1]) <= 0) {
|
||||
const periodEnd = periodStart.add({ days: duration - 1 });
|
||||
|
||||
if (periodEnd.compare(displayedDates[0]) >= 0) {
|
||||
const visibleStart = periodStart.compare(displayedDates[0]) >= 0 ? periodStart : displayedDates[0];
|
||||
const visibleEnd = periodEnd.compare(displayedDates[displayedDates.length - 1]) <= 0
|
||||
? periodEnd : displayedDates[displayedDates.length - 1];
|
||||
|
||||
const startIndex = displayedDates.findIndex(date => date.compare(visibleStart) === 0);
|
||||
|
||||
if (startIndex >= 0) {
|
||||
const spanDays = visibleEnd.toDate('UTC').getTime() - visibleStart.toDate('UTC').getTime();
|
||||
const span = Math.min(
|
||||
Math.floor(spanDays / (24 * 60 * 60 * 1000)) + 1,
|
||||
numCellsInRow - startIndex
|
||||
);
|
||||
|
||||
// Detect truncation
|
||||
const isTruncatedLeft = periodStart.compare(displayedDates[0]) < 0;
|
||||
const isTruncatedRight = periodEnd.compare(displayedDates[displayedDates.length - 1]) > 0;
|
||||
|
||||
let totalValue = 0;
|
||||
let hasAnyFailed = false;
|
||||
const periodDates: string[] = [];
|
||||
|
||||
for (let i = 0; i < duration; i++) {
|
||||
const periodDate = periodStart.add({ days: i });
|
||||
const dateStr = periodDate.toDate('UTC').toISOString().split('T')[0];
|
||||
periodDates.push(dateStr);
|
||||
|
||||
const dayCompletion = dailyCompletions.get(dateStr);
|
||||
if (dayCompletion) {
|
||||
totalValue += dayCompletion.value || 0;
|
||||
if (dayCompletion.failed === true) {
|
||||
hasAnyFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const habitStartDate = habit.startDate ? new CalendarDate(
|
||||
habit.startDate.getFullYear(),
|
||||
habit.startDate.getMonth() + 1,
|
||||
habit.startDate.getDate()
|
||||
) : null;
|
||||
|
||||
const isManuallyFailed = hasAnyFailed;
|
||||
const isPeriodInPast = periodEnd.compare(habitService.todaysDate) < 0;
|
||||
const isPeriodAfterHabitStart = habitStartDate ? periodStart.compare(habitStartDate) >= 0 : true;
|
||||
const isAutoFailed = !isManuallyFailed &&
|
||||
totalValue < (habit.target || 1) &&
|
||||
isPeriodInPast &&
|
||||
isPeriodAfterHabitStart;
|
||||
|
||||
const cellDateStr = periodStart.toDate('UTC').toISOString().split('T')[0];
|
||||
|
||||
cells.push({
|
||||
value: totalValue,
|
||||
date: cellDateStr,
|
||||
span,
|
||||
gridColumn: startIndex + 2,
|
||||
isFailed: isManuallyFailed || isAutoFailed,
|
||||
periodDates,
|
||||
isEmpty: false,
|
||||
isTruncatedLeft,
|
||||
isTruncatedRight
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
periodStart = periodStart.add({ days: duration });
|
||||
}
|
||||
|
||||
const covered = new Array(numCellsInRow).fill(false);
|
||||
cells.forEach(cell => {
|
||||
for (let i = 0; i < cell.span; i++) {
|
||||
const pos = (cell.gridColumn - 2) + i;
|
||||
if (pos >= 0 && pos < numCellsInRow) covered[pos] = true;
|
||||
}
|
||||
});
|
||||
|
||||
covered.forEach((isCovered, index) => {
|
||||
if (!isCovered) {
|
||||
cells.push({
|
||||
value: 0,
|
||||
date: '',
|
||||
span: 1,
|
||||
gridColumn: index + 2,
|
||||
isEmpty: true,
|
||||
isFailed: false,
|
||||
periodDates: [],
|
||||
isTruncatedLeft: false,
|
||||
isTruncatedRight: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return cells;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="w-full" bind:clientWidth={containerWidth}>
|
||||
<div class="w-full overflow-hidden">
|
||||
{#if skeleton && containerWidth === 0}
|
||||
<!-- Silent placeholder while measuring -->
|
||||
<div class="py-16"></div>
|
||||
{:else}
|
||||
<div class="grid gap-2 p-4 mx-auto" style="grid-template-columns: 120px repeat({numCellsInRow}, 48px) 120px; max-width: fit-content;">
|
||||
<!-- Navigation and Date Header -->
|
||||
{#if skeleton}
|
||||
<!-- Skeleton Header -->
|
||||
<div class="flex gap-2 justify-end items-center">
|
||||
<Skeleton class="h-4 w-4 rounded" />
|
||||
<Skeleton class="h-4 w-4 rounded" />
|
||||
</div>
|
||||
|
||||
{#each Array(numCellsInRow) as _, index (index)}
|
||||
<div class="flex justify-center">
|
||||
<Skeleton class="h-8 w-10 rounded" />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex gap-2 justify-start items-center">
|
||||
<Skeleton class="h-4 w-4 rounded" />
|
||||
<Skeleton class="h-4 w-4 rounded" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Real Header -->
|
||||
<div class="flex gap-2 justify-end items-center">
|
||||
<ChevronsLeft onclick={() => navigate(-(numCellsInRow - 1))} />
|
||||
<ChevronLeft onclick={() => navigate(-1)} />
|
||||
</div>
|
||||
|
||||
{#each displayedDates as date (date.toString())}
|
||||
<div><DateStrip.Item {date} /></div>
|
||||
{/each}
|
||||
|
||||
<div class="flex gap-2 justify-start items-center">
|
||||
<ChevronRight onclick={() => navigate(1)} />
|
||||
<ChevronsRight onclick={() => navigate(numCellsInRow - 1)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Habit Rows -->
|
||||
{#if skeleton}
|
||||
<!-- Skeleton Mode -->
|
||||
{#each Array(3) as _, habitIndex (habitIndex)}
|
||||
<!-- Habit Name with hover area -->
|
||||
<div class="font-medium text-right pr-2 flex items-center justify-end">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</div>
|
||||
|
||||
<!-- Habit Cells - each habit has consistent duration -->
|
||||
{#if habitIndex === 0}
|
||||
<!-- First habit: 1-day duration (daily habit) -->
|
||||
{#each Array(numCellsInRow) as _, cellIndex (cellIndex)}
|
||||
<div style="grid-column: {cellIndex + 2};" class="flex items-center justify-center">
|
||||
<Skeleton class="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
{/each}
|
||||
{:else if habitIndex === 1}
|
||||
<!-- Second habit: 3-day duration -->
|
||||
{#each Array(Math.floor(numCellsInRow / 3)) as _, periodIndex (periodIndex)}
|
||||
<div style="grid-column: {periodIndex * 3 + 2} / span 3;" class="flex items-center justify-center">
|
||||
<Skeleton class="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Partial period if needed -->
|
||||
{#if numCellsInRow % 3 !== 0}
|
||||
<div style="grid-column: {Math.floor(numCellsInRow / 3) * 3 + 2} / span {numCellsInRow % 3};" class="flex items-center justify-center">
|
||||
<Skeleton class="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Third habit: 2-day duration -->
|
||||
{#each Array(Math.floor(numCellsInRow / 2)) as _, periodIndex (periodIndex)}
|
||||
<div style="grid-column: {periodIndex * 2 + 2} / span 2;" class="flex items-center justify-center">
|
||||
<Skeleton class="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Partial period if needed -->
|
||||
{#if numCellsInRow % 2 !== 0}
|
||||
<div style="grid-column: {Math.floor(numCellsInRow / 2) * 2 + 2};" class="flex items-center justify-center">
|
||||
<Skeleton class="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Empty column -->
|
||||
<div></div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Real Data Mode -->
|
||||
{#each displayedHabits as habit (habit.id)}
|
||||
<div class="group relative font-medium text-right pr-2 flex items-center justify-end text-foreground">
|
||||
<!-- Action Menu - appears directly before name on hover -->
|
||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity duration-200 mr-1 flex items-center">
|
||||
<HabitActionMenu habitId={habit.id}/>
|
||||
</div>
|
||||
<!-- Habit Name -->
|
||||
<span class={habit.active ? "" : "opacity-50 line-through"}>
|
||||
{habit.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#each createHabitCells(habit, displayedDates, numCellsInRow) as cell (`${habit.id}-${cell.date}-${cell.gridColumn}`)}
|
||||
<div style="grid-column: {cell.gridColumn} / span {cell.span};" class="flex items-center justify-center">
|
||||
{#if cell.isEmpty}
|
||||
<div></div>
|
||||
{:else}
|
||||
<HabitCellContextMenu
|
||||
habit={habitService.toHabitWithCompletions(habit)}
|
||||
{cell}
|
||||
onResetPeriod={async () => { await habitService.resetPeriodByDates(habit.id, cell.periodDates); }}
|
||||
onSetValue={(totalValue, dailyValues) => habitService.setPeriodValue(habit.id, cell.periodDates, totalValue, dailyValues)}
|
||||
onToggleFailed={() => habitService.togglePeriodFailed(habit.id, cell.periodDates, cell.isFailed)}
|
||||
>
|
||||
<CounterHabitButton
|
||||
update={updateCompletion}
|
||||
habitId={habit.id}
|
||||
target={habit.target}
|
||||
increment={habit.increment}
|
||||
unit={habit.unit || ''}
|
||||
count={cell.value}
|
||||
date={cell.date}
|
||||
isToday={false}
|
||||
isFailed={cell.isFailed}
|
||||
length={cell.span}
|
||||
isTruncatedLeft={cell.isTruncatedLeft}
|
||||
isTruncatedRight={cell.isTruncatedRight}
|
||||
/>
|
||||
</HabitCellContextMenu>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Empty column where action menu used to be -->
|
||||
<div></div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { ChevronDown, ChevronUp } from '@lucide/svelte';
|
||||
import type { HabitWithCompletions } from '$lib/context/habits.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
habit,
|
||||
cell,
|
||||
onSetValue
|
||||
}: {
|
||||
open: boolean;
|
||||
habit: HabitWithCompletions;
|
||||
cell: {
|
||||
value: number;
|
||||
date: string;
|
||||
span: number;
|
||||
gridColumn: number;
|
||||
isFailed: boolean;
|
||||
isEmpty: boolean;
|
||||
periodDates: string[];
|
||||
};
|
||||
onSetValue: (totalValue: number, dailyValues?: Record<string, number>) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
let showAdvanced = $state(false);
|
||||
let manualInputValue = $state(0);
|
||||
let editableDailyValues = $state<Record<string, number>>({});
|
||||
|
||||
// Derived total value - either manual input or sum of daily values
|
||||
let totalValue = $derived(
|
||||
showAdvanced
|
||||
? Object.values(editableDailyValues).reduce((acc, val) => acc + (val || 0), 0)
|
||||
: manualInputValue
|
||||
);
|
||||
|
||||
// Reset values when dialog opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
manualInputValue = cell.value;
|
||||
showAdvanced = false;
|
||||
editableDailyValues = Object.fromEntries(cell.periodDates.map(date => [date, 0]));
|
||||
}
|
||||
});
|
||||
|
||||
// Update daily values when total changes (simple mode)
|
||||
function updateDailyValuesFromTotal() {
|
||||
if (!showAdvanced) return;
|
||||
|
||||
// Clear all daily values first
|
||||
cell.periodDates.forEach(date => {
|
||||
editableDailyValues[date] = 0;
|
||||
});
|
||||
|
||||
// Add total value to the most recent date
|
||||
if (cell.periodDates.length > 0 && manualInputValue > 0) {
|
||||
const mostRecentDate = cell.periodDates[cell.periodDates.length - 1];
|
||||
editableDailyValues[mostRecentDate] = manualInputValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleSubmit() {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
isSubmitting = true;
|
||||
|
||||
if (showAdvanced) {
|
||||
// Use individual daily values
|
||||
const cleanedDailyValues = Object.fromEntries(
|
||||
Object.entries(editableDailyValues).filter(([_, value]) => value > 0)
|
||||
);
|
||||
await onSetValue(totalValue, cleanedDailyValues);
|
||||
} else {
|
||||
// Use total value only
|
||||
await onSetValue(totalValue);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set value:', error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Set Completion Value</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Set the total value for this period ({cell.periodDates.length} day{cell.periodDates.length !== 1 ? 's' : ''})
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Total Value Input -->
|
||||
<div class="space-y-2">
|
||||
<Label for="total-value" class="text-sm font-medium">
|
||||
Total for period (currently {cell.value})
|
||||
</Label>
|
||||
<Input
|
||||
id="total-value"
|
||||
type="number"
|
||||
bind:value={manualInputValue}
|
||||
min="0"
|
||||
step={habit.increment || 1}
|
||||
disabled={isSubmitting}
|
||||
onchange={updateDailyValuesFromTotal}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Section -->
|
||||
{#if cell.periodDates.length > 1}
|
||||
<Collapsible.Root bind:open={showAdvanced}>
|
||||
<Collapsible.Trigger class="flex items-center gap-2 py-2 text-sm font-medium hover:text-primary transition-colors">
|
||||
{#if showAdvanced}
|
||||
<ChevronUp class="h-4 w-4" />
|
||||
{:else}
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
{/if}
|
||||
Advanced (set individual days)
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="space-y-3 pt-2">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each cell.periodDates as date (date)}
|
||||
<div class="space-y-1">
|
||||
<Label for="day-{date}" class="text-xs text-muted-foreground">
|
||||
{formatDate(date)}
|
||||
</Label>
|
||||
<Input
|
||||
id="day-{date}"
|
||||
type="number"
|
||||
bind:value={editableDailyValues[date]}
|
||||
min="0"
|
||||
step={habit.increment || 1}
|
||||
disabled={isSubmitting}
|
||||
class="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Individual day values will override the total above
|
||||
</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="flex gap-2">
|
||||
<Dialog.Close>
|
||||
<Button variant="outline" disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Button onclick={handleSubmit} disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
Applying...
|
||||
{:else}
|
||||
Apply
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user