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