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:
hiperman
2026-02-25 01:15:58 -05:00
parent f0891779fd
commit 3b9c179643
7 changed files with 1338 additions and 0 deletions
+192
View File
@@ -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>
+366
View File
@@ -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>