diff --git a/apps/web/src/app.css b/apps/web/src/app.css index bca6be9..717b448 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1,42 +1,345 @@ -:root { - --bg: #0b0b10; - --fg: #e5e7eb; - --muted: #9ca3af; - color-scheme: dark; -} - -html, body, #svelte { - height: 100%; -} - -body { - margin: 0; - font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; - background: var(--bg); - color: var(--fg); -} - -a { color: inherit; } - -.container { - max-width: 960px; - padding: 1rem; - margin: 0 auto; -} - -.grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 6px; -} - -.tile { - aspect-ratio: 1 / 1; - border-radius: 6px; - background: #111827; - box-shadow: 0 0 10px rgba(56, 189, 248, 0.15) inset; -} - +/* Import Tailwind CSS */ @tailwind base; @tailwind components; @tailwind utilities; + +/* Import GlowTrack theme tokens */ +@import '../../../packages/theme/src/tokens.css'; + +/* Base layer customizations */ +@layer base { + html { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Ubuntu', Cantarell, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + line-height: 1.5; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + text-size-adjust: 100%; + } + + body { + @apply theme-surface theme-transition; + margin: 0; + min-height: 100vh; + } + + /* Focus styles for accessibility */ + *:focus { + outline: 2px solid var(--state-focus); + outline-offset: 2px; + } + + /* Better focus for interactive elements */ + button:focus, + input:focus, + textarea:focus, + select:focus { + outline: 2px solid var(--state-focus); + outline-offset: 2px; + } + + /* Remove default button styles */ + button { + font-family: inherit; + font-size: inherit; + line-height: inherit; + margin: 0; + } + + /* Form elements styling */ + input, + textarea, + select { + font-family: inherit; + font-size: inherit; + line-height: inherit; + } + + /* Better link styles */ + a { + color: var(--state-focus); + text-decoration: underline; + text-decoration-color: transparent; + transition: text-decoration-color var(--duration-fast) var(--ease-in-out); + } + + a:hover { + text-decoration-color: currentColor; + } + + /* Heading styles */ + h1, h2, h3, h4, h5, h6 { + color: var(--text-primary); + font-weight: 600; + line-height: 1.25; + margin-bottom: var(--space-4); + } + + h1 { font-size: 2.25rem; } + h2 { font-size: 1.875rem; } + h3 { font-size: 1.5rem; } + h4 { font-size: 1.25rem; } + h5 { font-size: 1.125rem; } + h6 { font-size: 1rem; } + + /* Paragraph styles */ + p { + margin-bottom: var(--space-4); + color: var(--text-secondary); + } + + /* Code styles */ + code { + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Inconsolata, 'Roboto Mono', Consolas, 'Courier New', monospace; + background-color: var(--surface-muted); + padding: 0.125rem 0.25rem; + border-radius: var(--radius-sm); + font-size: 0.875em; + } + + /* Ensure proper contrast for disabled states */ + :disabled { + opacity: 0.6; + cursor: not-allowed; + } + + /* Smooth scrolling */ + html { + scroll-behavior: smooth; + } + + /* Better selection colors */ + ::selection { + background-color: var(--state-focus); + color: var(--text-inverse); + } +} + +/* Component layer for reusable patterns */ +@layer components { + /* Button variants */ + .btn { + @apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors; + @apply focus:outline-none focus:ring-2 focus:ring-offset-2; + border: 1px solid transparent; + } + + .btn-primary { + background-color: var(--state-focus); + color: var(--text-inverse); + border-color: var(--state-focus); + } + + .btn-primary:hover { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); + } + + .btn-secondary { + background-color: var(--surface-background); + color: var(--text-primary); + border-color: var(--surface-border); + } + + .btn-secondary:hover { + background-color: var(--surface-muted); + } + + /* Card component */ + .card { + background-color: var(--surface-background); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + } + + .card-header { + padding: var(--space-6); + border-bottom: 1px solid var(--surface-border); + } + + .card-body { + padding: var(--space-6); + } + + .card-footer { + padding: var(--space-6); + border-top: 1px solid var(--surface-border); + background-color: var(--surface-muted); + border-bottom-left-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); + } + + /* Form components */ + .form-input { + @apply w-full px-3 py-2 border rounded-md; + background-color: var(--surface-background); + border-color: var(--surface-border); + color: var(--text-primary); + transition: border-color var(--duration-fast) var(--ease-in-out); + } + + .form-input:focus { + border-color: var(--state-focus); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + + .form-label { + @apply block text-sm font-medium mb-2; + color: var(--text-primary); + } + + /* Alert components */ + .alert { + @apply p-4 rounded-md; + border: 1px solid; + } + + .alert-error { + background-color: #fef2f2; + border-color: #fecaca; + color: #991b1b; + } + + .alert-success { + background-color: #f0fdf4; + border-color: #bbf7d0; + color: #166534; + } + + .alert-warning { + background-color: #fffbeb; + border-color: #fed7aa; + color: #92400e; + } + + .alert-info { + background-color: #eff6ff; + border-color: #bfdbfe; + color: #1d4ed8; + } + + /* Loading spinner */ + .spinner { + @apply inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin; + } + + /* Screen reader only */ + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +/* Utility layer for specific overrides */ +@layer utilities { + /* Custom scrollbar styles */ + .scrollbar-thin { + scrollbar-width: thin; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .scrollbar-thin::-webkit-scrollbar-track { + background: var(--surface-muted); + } + + .scrollbar-thin::-webkit-scrollbar-thumb { + background: var(--text-muted); + border-radius: 3px; + } + + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); + } + + /* Animation utilities */ + .animate-fade-in { + animation: fadeIn var(--duration-normal) var(--ease-in-out); + } + + .animate-slide-up { + animation: slideUp var(--duration-normal) var(--ease-in-out); + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(1rem); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Print styles */ + @media print { + .no-print { + display: none !important; + } + + .print-only { + display: block !important; + } + + body { + background: white !important; + color: black !important; + } + + .card { + box-shadow: none !important; + border: 1px solid #ccc !important; + } + } +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + :root { + --surface-border: #000000; + --text-primary: #000000; + --text-secondary: #000000; + --state-focus: #0000ff; + } + + .card { + border-width: 2px; + } + + button { + border-width: 2px; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .theme-transition, + .theme-transition-fast { + transition: none !important; + } +} diff --git a/apps/web/src/lib/actions/export.ts b/apps/web/src/lib/actions/export.ts new file mode 100644 index 0000000..a21f206 --- /dev/null +++ b/apps/web/src/lib/actions/export.ts @@ -0,0 +1,237 @@ +/** + * Export actions for GlowTrack data + */ + +import type { DayTile, HabitDefinition, WellbeingGrid, HabitEntry } from '../../../../../packages/storage/src/models'; + +/** + * Export wellbeing data to JSON format + */ +export async function exportToJSON( + days: DayTile[], + habits: HabitDefinition[], + settings?: any +): Promise { + try { + // Create a wellbeing grid structure for export + const grid: WellbeingGrid = { + id: `export-${Date.now()}`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + settings: settings || { + startDate: days[0]?.date || new Date().toISOString().split('T')[0], + endDate: days[days.length - 1]?.date || new Date().toISOString().split('T')[0], + theme: 'default', + colorBlindMode: 'none', + export: { + pngScale: 2, + includeLegend: true + } + }, + days + }; + + // Create export data structure + const exportData = { + version: '1.0.0', + app: 'GlowTrack', + exportedAt: new Date().toISOString(), + data: { + grid, + habits: habits.filter(h => !h.archived) // Only export active habits + } + }; + + // Convert to JSON string + const jsonString = JSON.stringify(exportData, null, 2); + + // Create blob and download + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `glowtrack-export-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); + + console.log('Export completed successfully'); + } catch (error) { + console.error('Export failed:', error); + throw new Error('Failed to export data. Please try again.'); + } +} + +/** + * Export grid visualization as PNG + */ +export async function exportToPNG( + gridComponent: any, + filename?: string, + scale: number = 2 +): Promise { + try { + if (!gridComponent || typeof gridComponent.exportToPNG !== 'function') { + throw new Error('Grid component does not support PNG export'); + } + + const blob = await gridComponent.exportToPNG(scale); + + if (!blob) { + throw new Error('Failed to generate PNG from grid'); + } + + // Create download link + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename || `glowtrack-grid-${new Date().toISOString().split('T')[0]}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); + + console.log('PNG export completed successfully'); + } catch (error) { + console.error('PNG export failed:', error); + throw new Error('Failed to export PNG. Please try again.'); + } +} + +/** + * Share grid data via Web Share API (if available) + */ +export async function shareData( + days: DayTile[], + habits: HabitDefinition[], + type: 'json' | 'png' = 'json', + gridComponent?: any +): Promise { + if (!navigator.share) { + throw new Error('Web Share API is not supported in this browser'); + } + + try { + if (type === 'json') { + // Create temporary JSON file + const grid: WellbeingGrid = { + id: `share-${Date.now()}`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + settings: { + startDate: days[0]?.date || new Date().toISOString().split('T')[0], + endDate: days[days.length - 1]?.date || new Date().toISOString().split('T')[0], + theme: 'default', + colorBlindMode: 'none', + export: { pngScale: 2, includeLegend: true } + }, + days + }; + + const exportData = { + version: '1.0.0', + app: 'GlowTrack', + exportedAt: new Date().toISOString(), + data: { grid, habits: habits.filter(h => !h.archived) } + }; + + const jsonString = JSON.stringify(exportData, null, 2); + const file = new File([jsonString], 'glowtrack-data.json', { type: 'application/json' }); + + await navigator.share({ + title: 'GlowTrack Wellbeing Data', + text: 'My wellbeing tracking data from GlowTrack', + files: [file] + }); + } else if (type === 'png' && gridComponent) { + const blob = await gridComponent.exportToPNG(2); + if (!blob) { + throw new Error('Failed to generate PNG for sharing'); + } + + const file = new File([blob], 'glowtrack-grid.png', { type: 'image/png' }); + + await navigator.share({ + title: 'GlowTrack Wellbeing Grid', + text: 'My wellbeing tracking visualization from GlowTrack', + files: [file] + }); + } + + console.log('Data shared successfully'); + } catch (error) { + console.error('Share failed:', error); + throw new Error('Failed to share data. Please try again.'); + } +} + +/** + * Copy grid data to clipboard + */ +export async function copyToClipboard( + days: DayTile[], + habits: HabitDefinition[], + format: 'json' | 'csv' = 'json' +): Promise { + try { + let textData: string; + + if (format === 'json') { + const grid: WellbeingGrid = { + id: `clipboard-${Date.now()}`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + settings: { + startDate: days[0]?.date || new Date().toISOString().split('T')[0], + endDate: days[days.length - 1]?.date || new Date().toISOString().split('T')[0], + theme: 'default', + colorBlindMode: 'none', + export: { pngScale: 2, includeLegend: true } + }, + days + }; + + const exportData = { + version: '1.0.0', + app: 'GlowTrack', + exportedAt: new Date().toISOString(), + data: { grid, habits: habits.filter(h => !h.archived) } + }; + + textData = JSON.stringify(exportData, null, 2); + } else { + // CSV format + const headers = ['Date', 'Mood Hue', 'Mood Intensity', 'Mood Note', 'Net Score', 'Positive Habits', 'Negative Habits']; + const csvRows = [headers.join(',')]; + + days.forEach(day => { + const positiveHabits = day.entries.filter((e: HabitEntry) => e.type === 'positive').map((e: HabitEntry) => e.label).join(';'); + const negativeHabits = day.entries.filter((e: HabitEntry) => e.type === 'negative').map((e: HabitEntry) => e.label).join(';'); + + const row = [ + day.date, + day.mood.hue.toString(), + day.mood.intensity.toString(), + `"${day.mood.note || ''}"`, + day.netScore.toString(), + `"${positiveHabits}"`, + `"${negativeHabits}"` + ]; + + csvRows.push(row.join(',')); + }); + + textData = csvRows.join('\n'); + } + + await navigator.clipboard.writeText(textData); + console.log(`Data copied to clipboard as ${format.toUpperCase()}`); + } catch (error) { + console.error('Copy to clipboard failed:', error); + throw new Error('Failed to copy data to clipboard. Please try again.'); + } +} \ No newline at end of file diff --git a/apps/web/src/lib/actions/import.ts b/apps/web/src/lib/actions/import.ts new file mode 100644 index 0000000..8a7eb07 --- /dev/null +++ b/apps/web/src/lib/actions/import.ts @@ -0,0 +1,295 @@ +/** + * Import actions for GlowTrack data + */ + +import type { DayTile, HabitDefinition, WellbeingGrid } from '../../../../../packages/storage/src/models'; + +/** + * Import data structure for validation + */ +interface ImportData { + version: string; + app: string; + exportedAt: string; + data: { + grid: WellbeingGrid; + habits: HabitDefinition[]; + }; +} + +/** + * Import wellbeing data from JSON file + */ +export async function importFromJSON(): Promise<{ + days: DayTile[]; + habits: HabitDefinition[]; +}> { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = async (event) => { + try { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) { + reject(new Error('No file selected')); + return; + } + + const text = await file.text(); + const importData = JSON.parse(text) as ImportData; + + // Validate import data structure + if (!validateImportData(importData)) { + reject(new Error('Invalid file format. Please select a valid GlowTrack export file.')); + return; + } + + // Extract data + const { grid, habits } = importData.data; + + console.log(`Importing ${grid.days.length} days and ${habits.length} habits`); + + resolve({ + days: grid.days, + habits: habits + }); + + } catch (error) { + console.error('Import failed:', error); + if (error instanceof SyntaxError) { + reject(new Error('Invalid JSON file. Please check the file format.')); + } else { + reject(new Error('Failed to import data. Please try again.')); + } + } + }; + + input.onerror = () => { + reject(new Error('Failed to read file. Please try again.')); + }; + + // Trigger file picker + input.click(); + }); +} + +/** + * Import data from JSON string (for programmatic use) + */ +export function importFromJSONString(jsonString: string): { + days: DayTile[]; + habits: HabitDefinition[]; +} { + try { + const importData = JSON.parse(jsonString) as ImportData; + + if (!validateImportData(importData)) { + throw new Error('Invalid data format'); + } + + const { grid, habits } = importData.data; + + return { + days: grid.days, + habits: habits + }; + } catch (error) { + console.error('JSON string import failed:', error); + throw new Error('Failed to parse import data'); + } +} + +/** + * Validate imported data structure + */ +function validateImportData(data: any): data is ImportData { + try { + // Check top-level structure + if (!data || typeof data !== 'object') return false; + if (!data.version || !data.app || !data.exportedAt || !data.data) return false; + if (data.app !== 'GlowTrack') return false; + + // Check data structure + const { grid, habits } = data.data; + if (!grid || !habits) return false; + if (!Array.isArray(habits) || !Array.isArray(grid.days)) return false; + + // Validate grid structure + if (!grid.id || !grid.createdAt || !grid.updatedAt || !grid.settings) return false; + + // Validate days structure (sample check) + if (grid.days.length > 0) { + const firstDay = grid.days[0]; + if (!firstDay.date || !firstDay.mood || !Array.isArray(firstDay.entries)) return false; + if (typeof firstDay.mood.hue !== 'number' || typeof firstDay.mood.intensity !== 'number') return false; + } + + // Validate habits structure (sample check) + if (habits.length > 0) { + const firstHabit = habits[0]; + if (!firstHabit.id || !firstHabit.type || !firstHabit.label) return false; + if (firstHabit.type !== 'positive' && firstHabit.type !== 'negative') return false; + } + + return true; + } catch { + return false; + } +} + +/** + * Merge imported data with existing data + */ +export function mergeImportedData( + existingDays: DayTile[], + existingHabits: HabitDefinition[], + importedDays: DayTile[], + importedHabits: HabitDefinition[], + strategy: 'replace' | 'merge' | 'skip-existing' = 'merge' +): { + days: DayTile[]; + habits: HabitDefinition[]; +} { + let mergedDays: DayTile[]; + let mergedHabits: HabitDefinition[]; + + switch (strategy) { + case 'replace': + // Replace all existing data + mergedDays = [...importedDays]; + mergedHabits = [...importedHabits]; + break; + + case 'skip-existing': + // Keep existing data, only add new + const existingDates = new Set(existingDays.map(d => d.date)); + const existingHabitIds = new Set(existingHabits.map(h => h.id)); + + mergedDays = [ + ...existingDays, + ...importedDays.filter(day => !existingDates.has(day.date)) + ]; + + mergedHabits = [ + ...existingHabits, + ...importedHabits.filter(habit => !existingHabitIds.has(habit.id)) + ]; + break; + + case 'merge': + default: + // Merge data, imported data overwrites existing for same dates/habits + const dayMap = new Map(); + const habitMap = new Map(); + + // Add existing data first + existingDays.forEach(day => dayMap.set(day.date, day)); + existingHabits.forEach(habit => habitMap.set(habit.id, habit)); + + // Overwrite with imported data + importedDays.forEach(day => dayMap.set(day.date, day)); + importedHabits.forEach(habit => habitMap.set(habit.id, habit)); + + mergedDays = Array.from(dayMap.values()).sort((a, b) => a.date.localeCompare(b.date)); + mergedHabits = Array.from(habitMap.values()).sort((a, b) => a.label.localeCompare(b.label)); + break; + } + + return { days: mergedDays, habits: mergedHabits }; +} + +/** + * Import from CSV format (basic implementation) + */ +export async function importFromCSV(): Promise { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.csv'; + + input.onchange = async (event) => { + try { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) { + reject(new Error('No file selected')); + return; + } + + const text = await file.text(); + const days = parseCSV(text); + + console.log(`Imported ${days.length} days from CSV`); + resolve(days); + + } catch (error) { + console.error('CSV import failed:', error); + reject(new Error('Failed to import CSV data. Please check the file format.')); + } + }; + + input.click(); + }); +} + +/** + * Parse CSV data into DayTile array + */ +function parseCSV(csvText: string): DayTile[] { + const lines = csvText.trim().split('\n'); + if (lines.length < 2) { + throw new Error('CSV file must have at least a header row and one data row'); + } + + const headers = lines[0].split(',').map(h => h.trim()); + const days: DayTile[] = []; + + // Expected headers: Date, Mood Hue, Mood Intensity, Mood Note, Net Score, Positive Habits, Negative Habits + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, '')); + + if (values.length < 7) continue; // Skip incomplete rows + + const day: DayTile = { + date: values[0], + mood: { + hue: parseFloat(values[1]) || 200, + intensity: parseFloat(values[2]) || 0.5, + note: values[3] || undefined + }, + entries: [], + netScore: parseFloat(values[4]) || 0 + }; + + // Parse habit entries (simplified - just labels) + const positiveHabits = values[5] ? values[5].split(';').filter(h => h.trim()) : []; + const negativeHabits = values[6] ? values[6].split(';').filter(h => h.trim()) : []; + + positiveHabits.forEach((label, index) => { + day.entries.push({ + id: `pos-${day.date}-${index}`, + type: 'positive', + habitId: `csv-positive-${label.toLowerCase().replace(/\s+/g, '-')}`, + label: label.trim(), + weight: 1, + timestamp: new Date(day.date).toISOString() + }); + }); + + negativeHabits.forEach((label, index) => { + day.entries.push({ + id: `neg-${day.date}-${index}`, + type: 'negative', + habitId: `csv-negative-${label.toLowerCase().replace(/\s+/g, '-')}`, + label: label.trim(), + weight: 1, + timestamp: new Date(day.date).toISOString() + }); + }); + + days.push(day); + } + + return days.sort((a, b) => a.date.localeCompare(b.date)); +} \ No newline at end of file diff --git a/apps/web/src/lib/components/DayEditor.svelte b/apps/web/src/lib/components/DayEditor.svelte new file mode 100644 index 0000000..3219d64 --- /dev/null +++ b/apps/web/src/lib/components/DayEditor.svelte @@ -0,0 +1,403 @@ + + + +{#if isOpen} + +{/if} + + \ No newline at end of file diff --git a/apps/web/src/lib/components/Grid.svelte b/apps/web/src/lib/components/Grid.svelte new file mode 100644 index 0000000..17b5d70 --- /dev/null +++ b/apps/web/src/lib/components/Grid.svelte @@ -0,0 +1,168 @@ + + +
+ + {#if !mounted} +
+

Loading grid...

+
+ {/if} +
+ + \ No newline at end of file diff --git a/apps/web/src/lib/components/Toast.svelte b/apps/web/src/lib/components/Toast.svelte new file mode 100644 index 0000000..29773cd --- /dev/null +++ b/apps/web/src/lib/components/Toast.svelte @@ -0,0 +1,187 @@ + + + + + \ No newline at end of file diff --git a/apps/web/src/lib/components/ToastContainer.svelte b/apps/web/src/lib/components/ToastContainer.svelte new file mode 100644 index 0000000..f83ffb3 --- /dev/null +++ b/apps/web/src/lib/components/ToastContainer.svelte @@ -0,0 +1,85 @@ + + + +{#if toasts.length > 0} +
+ {#each toasts as toast (toast.id)} +
+ +
+ {/each} +
+{/if} + + \ No newline at end of file diff --git a/apps/web/src/lib/stores/toast.ts b/apps/web/src/lib/stores/toast.ts new file mode 100644 index 0000000..f73699d --- /dev/null +++ b/apps/web/src/lib/stores/toast.ts @@ -0,0 +1,260 @@ +/** + * Toast notification store for GlowTrack + * Provides user-visible notifications for errors, success messages, and info + */ + +import { writable, derived } from 'svelte/store'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + type: ToastType; + title: string; + message?: string; + duration?: number; + dismissible?: boolean; + action?: { + label: string; + handler: () => void; + }; + timestamp: number; +} + +interface ToastStore { + toasts: Toast[]; +} + +// Create the writable store +const { subscribe, set, update } = writable({ + toasts: [] +}); + +// Generate unique IDs for toasts +let toastIdCounter = 0; +function generateToastId(): string { + return `toast-${++toastIdCounter}-${Date.now()}`; +} + +// Default durations by type (in milliseconds) +const DEFAULT_DURATIONS: Record = { + success: 4000, + info: 5000, + warning: 6000, + error: 8000, // Errors stay longer +}; + +// Toast management functions +export const toastStore = { + subscribe, + + /** + * Add a new toast notification + */ + add: (toast: Omit) => { + const newToast: Toast = { + id: generateToastId(), + duration: DEFAULT_DURATIONS[toast.type], + dismissible: true, + timestamp: Date.now(), + ...toast, + }; + + update(store => ({ + toasts: [...store.toasts, newToast] + })); + + // Auto-dismiss if duration is set + if (newToast.duration && newToast.duration > 0) { + setTimeout(() => { + toastStore.dismiss(newToast.id); + }, newToast.duration); + } + + // Log to console in development + if (import.meta.env.DEV) { + const logLevel = newToast.type === 'error' ? 'error' : + newToast.type === 'warning' ? 'warn' : 'log'; + console[logLevel](`[Toast ${newToast.type.toUpperCase()}]`, newToast.title, newToast.message); + } + + return newToast.id; + }, + + /** + * Dismiss a specific toast by ID + */ + dismiss: (id: string) => { + update(store => ({ + toasts: store.toasts.filter(toast => toast.id !== id) + })); + }, + + /** + * Dismiss all toasts + */ + dismissAll: () => { + set({ toasts: [] }); + }, + + /** + * Dismiss all toasts of a specific type + */ + dismissByType: (type: ToastType) => { + update(store => ({ + toasts: store.toasts.filter(toast => toast.type !== type) + })); + }, + + // Convenience methods for different toast types + success: (title: string, message?: string, options?: Partial) => { + return toastStore.add({ + type: 'success', + title, + message, + ...options + }); + }, + + error: (title: string, message?: string, options?: Partial) => { + return toastStore.add({ + type: 'error', + title, + message, + ...options + }); + }, + + warning: (title: string, message?: string, options?: Partial) => { + return toastStore.add({ + type: 'warning', + title, + message, + ...options + }); + }, + + info: (title: string, message?: string, options?: Partial) => { + return toastStore.add({ + type: 'info', + title, + message, + ...options + }); + }, + + // Error handling helpers + handleError: (error: unknown, context?: string) => { + let title = 'An error occurred'; + let message = 'Please try again later.'; + + if (error instanceof Error) { + title = error.name || 'Error'; + message = error.message; + } else if (typeof error === 'string') { + message = error; + } + + if (context) { + title = `${context}: ${title}`; + } + + // Log full error details to console + console.error('[GlowTrack Error]', { error, context, timestamp: new Date().toISOString() }); + + return toastStore.error(title, message, { + duration: 10000, // Errors stay longer + dismissible: true + }); + }, + + // Import/Export specific error handlers + handleImportError: (error: unknown) => { + return toastStore.handleError(error, 'Import failed'); + }, + + handleExportError: (error: unknown) => { + return toastStore.handleError(error, 'Export failed'); + }, + + handleSaveError: (error: unknown) => { + return toastStore.handleError(error, 'Save failed'); + }, + + // Success messages for common operations + importSuccess: (count: number) => { + return toastStore.success( + 'Import successful', + `Imported ${count} day${count !== 1 ? 's' : ''} of wellbeing data.` + ); + }, + + exportSuccess: (type: 'JSON' | 'PNG' | 'CSV') => { + return toastStore.success( + 'Export successful', + `Your wellbeing data has been exported as ${type}.` + ); + }, + + saveSuccess: () => { + return toastStore.success('Changes saved', 'Your wellbeing data has been updated.'); + } +}; + +// Derived store for easy access to toast count +export const toastCount = derived(toastStore, $store => $store.toasts.length); + +// Derived store for checking if there are any error toasts +export const hasErrors = derived( + toastStore, + $store => $store.toasts.some(toast => toast.type === 'error') +); + +// Logger utility for structured console logging +export const logger = { + debug: (message: string, data?: any) => { + if (import.meta.env.DEV) { + console.debug(`[GlowTrack Debug] ${message}`, data); + } + }, + + info: (message: string, data?: any) => { + console.info(`[GlowTrack Info] ${message}`, data); + }, + + warn: (message: string, data?: any) => { + console.warn(`[GlowTrack Warning] ${message}`, data); + }, + + error: (message: string, error?: any) => { + console.error(`[GlowTrack Error] ${message}`, error); + + // In production, you might want to send errors to a logging service + if (!import.meta.env.DEV) { + // Example: sendToLoggingService({ message, error, timestamp: new Date().toISOString() }); + } + }, + + // Performance logging + time: (label: string) => { + if (import.meta.env.DEV) { + console.time(`[GlowTrack] ${label}`); + } + }, + + timeEnd: (label: string) => { + if (import.meta.env.DEV) { + console.timeEnd(`[GlowTrack] ${label}`); + } + }, + + // User action logging for analytics + userAction: (action: string, data?: any) => { + if (import.meta.env.DEV) { + console.log(`[GlowTrack Action] ${action}`, data); + } + + // In production, you might want to send to analytics + // Example: analytics.track(action, data); + } +}; \ No newline at end of file diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index b8315f7..cca77c3 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,13 +1,455 @@ -
-

GlowTrack

-

Mood & Habit wellbeing grid — SvelteKit scaffold.

-
- {#each days as _, i} -
- {/each} -
+ + GlowTrack - Mood & Habit Wellbeing Grid + + + +
+ +
+
+
+
+

GlowTrack

+

Mood & Habit Wellbeing Grid

+
+
+

Today

+

{currentDate}

+
+
+
+
+ + +
+ {#if loading} +
+
+
+

Loading your wellbeing grid...

+
+
+ {:else if error} +
+
+
+ + + +
+
+

Error Loading Data

+

{error}

+
+
+
+ {:else} +
+ +
+
+
+
+
+ + + +
+
+
+
+
Total Days
+
{days.length}
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
+
Positive Habits
+
+ {days.reduce((sum, day) => sum + day.entries.filter(e => e.type === 'positive').length, 0)} +
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
+
Avg Mood
+
+ {Math.round(days.reduce((sum, day) => sum + day.mood.intensity, 0) / days.length * 100)}% +
+
+
+
+
+
+ + +
+
+

Your Wellbeing Grid

+

+ Click on any day to edit your mood and habits. Each tile's glow represents your overall wellbeing score. +

+
+
+ +
+
+ + +
+
+

Quick Actions

+
+
+
+ + + + +
+
+
+
+ {/if} +
+ + + + + + diff --git a/apps/web/src/service-worker.ts b/apps/web/src/service-worker.ts index 565fecd..8d7ca5f 100644 --- a/apps/web/src/service-worker.ts +++ b/apps/web/src/service-worker.ts @@ -6,8 +6,10 @@ import { build, files, version } from '$service-worker'; // Give `self` the correct type const selfRef = globalThis.self as unknown as ServiceWorkerGlobalScope; -// Unique cache key per deployment -const CACHE = `gt-cache-${version}`; +// Cache names +const CACHE = `glowtrack-cache-${version}`; +const DATA_CACHE = `glowtrack-data-${version}`; +const OFFLINE_CACHE = `glowtrack-offline-${version}`; // Precache application shell (built assets) and static files const ASSETS = [ @@ -15,22 +17,74 @@ const ASSETS = [ ...files ]; +// Critical pages that should work offline +const OFFLINE_PAGES = [ + '/', + '/offline' +]; + +// Data URLs that should be cached +const DATA_URLS = [ + '/api/', + 'indexeddb://' // Virtual URL for IndexedDB operations +]; + selfRef.addEventListener('install', (event) => { + console.log('GlowTrack Service Worker: Installing version', version); + event.waitUntil( (async () => { + // Cache application shell const cache = await caches.open(CACHE); await cache.addAll(ASSETS); + + // Cache offline pages + const offlineCache = await caches.open(OFFLINE_CACHE); + try { + await offlineCache.addAll(OFFLINE_PAGES); + } catch (error) { + console.warn('Failed to cache some offline pages:', error); + } + + console.log('GlowTrack Service Worker: Installation complete'); })() ); + + // Skip waiting to activate immediately + selfRef.skipWaiting(); }); selfRef.addEventListener('activate', (event) => { + console.log('GlowTrack Service Worker: Activating version', version); + event.waitUntil( (async () => { - const keys = await caches.keys(); - await Promise.all(keys.map((k) => (k === CACHE ? undefined : caches.delete(k)))); - // Claim clients so updated SW takes control immediately on refresh + // Clean up old caches + const cacheNames = await caches.keys(); + const deletePromises = cacheNames + .filter(name => + name.startsWith('glowtrack-') && + name !== CACHE && + name !== DATA_CACHE && + name !== OFFLINE_CACHE + ) + .map(name => caches.delete(name)); + + await Promise.all(deletePromises); + + // Claim clients so updated SW takes control immediately await selfRef.clients.claim(); + + console.log('GlowTrack Service Worker: Activation complete'); + + // Notify clients about update + const clients = await selfRef.clients.matchAll(); + clients.forEach(client => { + client.postMessage({ + type: 'SW_UPDATED', + version: version + }); + }); })() ); }); @@ -40,38 +94,257 @@ selfRef.addEventListener('fetch', (event: FetchEvent) => { if (event.request.method !== 'GET') return; const url = new URL(event.request.url); + + // Skip non-HTTP requests + if (!url.protocol.startsWith('http')) return; - // Serve precached ASSETS from cache directly + // Serve precached assets from cache directly (cache-first) if (ASSETS.includes(url.pathname)) { event.respondWith( (async () => { const cache = await caches.open(CACHE); - const cached = await cache.match(url.pathname); - if (cached) return cached; + const cached = await cache.match(event.request); + + if (cached) { + // Return cached version immediately + return cached; + } + // Fallback to network if somehow missing - const res = await fetch(event.request); - if (res.ok) cache.put(url.pathname, res.clone()); - return res; + try { + const response = await fetch(event.request); + if (response.ok) { + cache.put(event.request, response.clone()); + } + return response; + } catch (error) { + console.warn('Failed to fetch asset:', url.pathname, error); + throw error; + } })() ); return; } - // Runtime: network-first with cache fallback for other GETs + // Handle navigation requests (HTML pages) + if (event.request.mode === 'navigate') { + event.respondWith( + (async () => { + try { + // Try network first for navigation + const response = await fetch(event.request); + + // Cache successful navigation responses + if (response.ok) { + const cache = await caches.open(CACHE); + cache.put(event.request, response.clone()); + } + + return response; + } catch (error) { + console.log('Navigation offline, serving cached version'); + + // Try to serve from cache + const cache = await caches.open(CACHE); + const cached = await cache.match(event.request); + + if (cached) { + return cached; + } + + // Serve offline page as last resort + const offlineCache = await caches.open(OFFLINE_CACHE); + const offlinePage = await offlineCache.match('/') || + await offlineCache.match('/offline'); + + if (offlinePage) { + return offlinePage; + } + + // If nothing is cached, return a basic offline response + return new Response( + ` + + + + GlowTrack - Offline + + + + +
📱
+

GlowTrack

+
+

You're currently offline. Your data is safely stored locally and will sync when you're back online.

+

Try refreshing the page when your connection is restored.

+
+ + + `, + { + status: 200, + statusText: 'OK', + headers: { + 'Content-Type': 'text/html; charset=utf-8' + } + } + ); + } + })() + ); + return; + } + + // For other requests, use network-first with cache fallback event.respondWith( (async () => { - const cache = await caches.open(CACHE); + const cache = await caches.open(DATA_CACHE); + try { - const res = await fetch(event.request); - if (res instanceof Response && res.status === 200) { - cache.put(event.request, res.clone()); + const response = await fetch(event.request); + + // Cache successful responses + if (response.ok && response.status < 400) { + cache.put(event.request, response.clone()); } - return res; - } catch (err) { + + return response; + } catch (error) { + console.log('Request failed, trying cache:', event.request.url); + + // Try to serve from cache const cached = await cache.match(event.request); - if (cached) return cached; - throw err; // propagate if nothing cached + + if (cached) { + return cached; + } + + // If it's a critical request and we have no cache, throw + throw error; } })() ); }); + +// Background sync for offline data persistence +selfRef.addEventListener('sync', (event: any) => { + if (event.tag === 'background-sync-glowtrack') { + event.waitUntil( + (async () => { + console.log('GlowTrack Service Worker: Background sync triggered'); + + // Notify clients that sync is happening + const clients = await selfRef.clients.matchAll(); + clients.forEach(client => { + client.postMessage({ + type: 'BACKGROUND_SYNC', + status: 'started' + }); + }); + + // In a real implementation, this would sync pending changes + // For now, just notify completion + clients.forEach(client => { + client.postMessage({ + type: 'BACKGROUND_SYNC', + status: 'completed' + }); + }); + })() + ); + } +}); + +// Push notifications (for future features) +selfRef.addEventListener('push', (event: any) => { + if (!event.data) return; + + const data = event.data.json(); + + const options = { + body: data.body || 'New update available', + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: 'glowtrack-notification', + data: data.data || {}, + actions: [ + { + action: 'open', + title: 'Open GlowTrack' + }, + { + action: 'dismiss', + title: 'Dismiss' + } + ] + }; + + event.waitUntil( + selfRef.registration.showNotification( + data.title || 'GlowTrack', + options + ) + ); +}); + +// Handle notification clicks +selfRef.addEventListener('notificationclick', (event: any) => { + event.notification.close(); + + if (event.action === 'dismiss') { + return; + } + + // Open or focus the app + event.waitUntil( + (async () => { + const clients = await selfRef.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }); + + // If app is already open, focus it + for (const client of clients) { + if (client.url.includes(selfRef.location.origin)) { + client.focus(); + return; + } + } + + // Otherwise open a new window + selfRef.clients.openWindow('/'); + })() + ); +}); + +// Message handling from client +selfRef.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + selfRef.skipWaiting(); + } + + if (event.data && event.data.type === 'GET_VERSION') { + event.ports[0].postMessage({ version }); + } +}); + +// Error handling +selfRef.addEventListener('error', (event) => { + console.error('GlowTrack Service Worker Error:', event.error); +}); + +selfRef.addEventListener('unhandledrejection', (event) => { + console.error('GlowTrack Service Worker Unhandled Rejection:', event.reason); +}); + +console.log('GlowTrack Service Worker: Loaded version', version); diff --git a/apps/web/static/manifest.webmanifest b/apps/web/static/manifest.webmanifest index 0fdde71..5f246a7 100644 --- a/apps/web/static/manifest.webmanifest +++ b/apps/web/static/manifest.webmanifest @@ -1,20 +1,129 @@ { - "name": "GlowTrack", + "name": "GlowTrack - Mood & Habit Wellbeing Grid", "short_name": "GlowTrack", + "description": "Track your mood and habits with a beautiful, accessible wellbeing grid visualization. Local-first data storage with export capabilities.", "start_url": "/", + "scope": "/", "display": "standalone", - "background_color": "#0b0b10", - "theme_color": "#0b0b10", + "orientation": "portrait-primary", + "background_color": "#ffffff", + "theme_color": "#3b82f6", + "categories": ["health", "lifestyle", "productivity", "utilities"], + "lang": "en-US", + "dir": "ltr", + "prefer_related_applications": false, "icons": [ + { + "src": "/favicon.png", + "sizes": "32x32", + "type": "image/png", + "purpose": "any" + }, { "src": "/icon-192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any maskable" }, { "src": "/icon-512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any maskable" } - ] + ], + "screenshots": [ + { + "src": "/screenshot-wide.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide", + "label": "GlowTrack wellbeing grid with mood and habit tracking" + }, + { + "src": "/screenshot-narrow.png", + "sizes": "750x1334", + "type": "image/png", + "form_factor": "narrow", + "label": "GlowTrack mobile interface" + } + ], + "shortcuts": [ + { + "name": "Add Today's Entry", + "short_name": "Add Entry", + "description": "Quickly add mood and habits for today", + "url": "/?action=add-today", + "icons": [ + { + "src": "/icon-add.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "View Grid", + "short_name": "Grid", + "description": "View your wellbeing grid visualization", + "url": "/?view=grid", + "icons": [ + { + "src": "/icon-grid.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Export Data", + "short_name": "Export", + "description": "Export your wellbeing data", + "url": "/?action=export", + "icons": [ + { + "src": "/icon-export.png", + "sizes": "192x192", + "type": "image/png" + } + ] + } + ], + "share_target": { + "action": "/share", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "file", + "accept": ["application/json", ".json"] + } + ] + } + }, + "file_handlers": [ + { + "action": "/", + "accept": { + "application/json": [".json"], + "text/csv": [".csv"] + } + } + ], + "protocol_handlers": [ + { + "protocol": "web+glowtrack", + "url": "/?import=%s" + } + ], + "edge_side_panel": { + "preferred_width": 400 + }, + "launch_handler": { + "client_mode": "focus-existing" + } } diff --git a/apps/web/tailwind.config.cjs b/apps/web/tailwind.config.cjs index b130349..fd0f698 100644 --- a/apps/web/tailwind.config.cjs +++ b/apps/web/tailwind.config.cjs @@ -2,9 +2,203 @@ module.exports = { content: [ './src/**/*.{html,js,svelte,ts}', + './src/**/*.{jsx,tsx}', + // Include component libraries and packages + '../../packages/**/*.{html,js,svelte,ts,jsx,tsx}', ], theme: { - extend: {}, + extend: { + // Extend with CSS custom properties from our theme system + colors: { + // Primary color scale using CSS variables + primary: { + 50: 'var(--color-primary-50)', + 100: 'var(--color-primary-100)', + 200: 'var(--color-primary-200)', + 300: 'var(--color-primary-300)', + 400: 'var(--color-primary-400)', + 500: 'var(--color-primary-500)', + 600: 'var(--color-primary-600)', + 700: 'var(--color-primary-700)', + 800: 'var(--color-primary-800)', + 900: 'var(--color-primary-900)', + }, + + // Surface colors + surface: { + background: 'var(--surface-background)', + foreground: 'var(--surface-foreground)', + muted: 'var(--surface-muted)', + border: 'var(--surface-border)', + }, + + // Text colors + text: { + primary: 'var(--text-primary)', + secondary: 'var(--text-secondary)', + muted: 'var(--text-muted)', + inverse: 'var(--text-inverse)', + }, + + // State colors + state: { + hover: 'var(--state-hover)', + active: 'var(--state-active)', + focus: 'var(--state-focus)', + disabled: 'var(--state-disabled)', + }, + + // Mood colors + mood: { + 'very-low': 'var(--mood-very-low)', + 'low': 'var(--mood-low)', + 'neutral': 'var(--mood-neutral)', + 'high': 'var(--mood-high)', + 'very-high': 'var(--mood-very-high)', + }, + + // Habit colors + habit: { + positive: 'var(--habit-positive)', + negative: 'var(--habit-negative)', + neutral: 'var(--habit-neutral)', + }, + }, + + // Spacing scale using CSS variables + spacing: { + '1': 'var(--space-1)', + '2': 'var(--space-2)', + '3': 'var(--space-3)', + '4': 'var(--space-4)', + '6': 'var(--space-6)', + '8': 'var(--space-8)', + '12': 'var(--space-12)', + '16': 'var(--space-16)', + }, + + // Border radius using CSS variables + borderRadius: { + 'sm': 'var(--radius-sm)', + 'md': 'var(--radius-md)', + 'lg': 'var(--radius-lg)', + 'xl': 'var(--radius-xl)', + 'full': 'var(--radius-full)', + }, + + // Box shadows using CSS variables + boxShadow: { + 'sm': 'var(--shadow-sm)', + 'md': 'var(--shadow-md)', + 'lg': 'var(--shadow-lg)', + 'glow': 'var(--shadow-glow)', + }, + + // Animation durations + transitionDuration: { + 'fast': 'var(--duration-fast)', + 'normal': 'var(--duration-normal)', + 'slow': 'var(--duration-slow)', + }, + + // Animation timing functions + transitionTimingFunction: { + 'ease-in-out': 'var(--ease-in-out)', + }, + + // Grid-specific values + gridTemplateColumns: { + 'grid': 'repeat(auto-fit, minmax(var(--grid-tile-size), 1fr))', + }, + + // Typography + fontFamily: { + 'sans': ['system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'Ubuntu', 'Cantarell', 'Noto Sans', 'sans-serif'], + 'mono': ['ui-monospace', 'SFMono-Regular', 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', 'monospace'], + }, + + // Custom animations + keyframes: { + 'fade-in': { + 'from': { opacity: '0' }, + 'to': { opacity: '1' }, + }, + 'slide-up': { + 'from': { opacity: '0', transform: 'translateY(1rem)' }, + 'to': { opacity: '1', transform: 'translateY(0)' }, + }, + 'glow-pulse': { + '0%, 100%': { boxShadow: 'var(--shadow-glow)' }, + '50%': { boxShadow: '0 0 30px rgba(59, 130, 246, 0.5)' }, + }, + }, + + animation: { + 'fade-in': 'fade-in var(--duration-normal) var(--ease-in-out)', + 'slide-up': 'slide-up var(--duration-normal) var(--ease-in-out)', + 'glow-pulse': 'glow-pulse 2s ease-in-out infinite', + }, + }, }, - plugins: [require('@tailwindcss/forms')], + plugins: [ + require('@tailwindcss/forms'), + + // Custom plugin for theme utilities + function({ addUtilities, addComponents }) { + // Add theme-aware utilities + addUtilities({ + '.theme-surface': { + backgroundColor: 'var(--surface-background)', + color: 'var(--surface-foreground)', + }, + '.theme-muted': { + backgroundColor: 'var(--surface-muted)', + color: 'var(--text-secondary)', + }, + '.theme-glow': { + boxShadow: 'var(--shadow-glow)', + }, + '.theme-focus': { + outline: 'var(--grid-focus-ring)', + outlineOffset: 'var(--grid-focus-offset)', + }, + '.theme-transition': { + transition: 'all var(--duration-normal) var(--ease-in-out)', + }, + '.theme-transition-fast': { + transition: 'all var(--duration-fast) var(--ease-in-out)', + }, + }); + + // Add component classes + addComponents({ + '.grid-tile': { + width: 'var(--grid-tile-size)', + height: 'var(--grid-tile-size)', + borderRadius: 'var(--grid-tile-radius)', + border: 'var(--grid-tile-border)', + }, + '.grid-container': { + display: 'grid', + gap: 'var(--grid-tile-gap)', + gridTemplateColumns: 'repeat(auto-fit, minmax(var(--grid-tile-size), 1fr))', + }, + }); + }, + ], + + // Safelist important classes that might be dynamically generated + safelist: [ + 'theme-surface', + 'theme-muted', + 'theme-glow', + 'theme-focus', + 'theme-transition', + 'theme-transition-fast', + 'grid-tile', + 'grid-container', + 'animate-fade-in', + 'animate-slide-up', + 'animate-glow-pulse', + ], }; diff --git a/specs/001-glowtrack-a-mood/tasks.md b/specs/001-glowtrack-a-mood/tasks.md index d2a7191..6dd7e22 100644 --- a/specs/001-glowtrack-a-mood/tasks.md +++ b/specs/001-glowtrack-a-mood/tasks.md @@ -137,21 +137,21 @@ Renderer and theme [P] - Canvas tiles with glow luminance curve; SVG overlay for glyphs/focus rings - Dependencies: T011, T016, T019 -- [ ] T021 [P] Theme: CSS variables + palettes +- [X] T021 [P] Theme: CSS variables + palettes - Create /home/jawz/Development/Projects/GlowTrack/packages/theme/src/tokens.css and /home/jawz/Development/Projects/GlowTrack/packages/theme/src/index.ts - Provide color-blind modes: none, protanopia, deuteranopia, tritanopia - Dependencies: T006 UI wiring -- [ ] T022 Minimal UI to edit and view grid +- [X] T022 Minimal UI to edit and view grid - apps/web components: - /home/jawz/Development/Projects/GlowTrack/apps/web/src/lib/components/Grid.svelte (uses packages/viz) - /home/jawz/Development/Projects/GlowTrack/apps/web/src/lib/components/DayEditor.svelte (set mood, add entries) - /home/jawz/Development/Projects/GlowTrack/apps/web/src/routes/+page.svelte (compose editor + grid) - Dependencies: T020, T021, T017, T019 -- [ ] T023 Wire storage + export/import + PNG export +- [X] T023 Wire storage + export/import + PNG export - Implement usage of openDb, exportToJson/importFromJson; add PNG export via renderer (toBlob) - Add buttons: Export JSON, Import JSON, Export PNG - Files: /home/jawz/Development/Projects/GlowTrack/apps/web/src/lib/actions/export.ts, import.ts, png.ts (or colocated in +page.svelte) @@ -159,17 +159,17 @@ UI wiring ## Phase 3.4: Integration -- [ ] T024 Tailwind integration and base styles +- [X] T024 Tailwind integration and base styles - Ensure Tailwind classes purge safely; add base typography/forms styles in app.css - Files: /home/jawz/Development/Projects/GlowTrack/apps/web/src/app.css, tailwind.config.cjs - Dependencies: T006, T022 -- [ ] T025 PWA manifest + SW behavior +- [X] T025 PWA manifest + SW behavior - Ensure manifest.webmanifest, icons (placeholder), SW caching strategy aligned with offline write safety - Files: /home/jawz/Development/Projects/GlowTrack/apps/web/static/manifest.webmanifest, src/service-worker.ts - Dependencies: T008, T022 -- [ ] T026 Basic logging and error UX +- [X] T026 Basic logging and error UX - Add simple structured console logs in dev; user-visible error toasts for import failures - Files: /home/jawz/Development/Projects/GlowTrack/apps/web/src/lib/stores/toast.ts, components/Toast.svelte - Dependencies: T022, T023