Compare commits
1 Commits
0db0804e51
...
001-glowtr
| Author | SHA1 | Date | |
|---|---|---|---|
| 03b2a84b3c |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
237
apps/web/src/lib/actions/export.ts
Normal file
237
apps/web/src/lib/actions/export.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
295
apps/web/src/lib/actions/import.ts
Normal file
295
apps/web/src/lib/actions/import.ts
Normal file
@@ -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<string, DayTile>();
|
||||
const habitMap = new Map<string, HabitDefinition>();
|
||||
|
||||
// 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<DayTile[]> {
|
||||
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));
|
||||
}
|
||||
403
apps/web/src/lib/components/DayEditor.svelte
Normal file
403
apps/web/src/lib/components/DayEditor.svelte
Normal file
@@ -0,0 +1,403 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { DayTile, Mood, HabitEntry, HabitDefinition, HabitType } from '@glowtrack/storage';
|
||||
|
||||
// Props
|
||||
export let day: DayTile | null = null;
|
||||
export let habits: HabitDefinition[] = [];
|
||||
export let isOpen = false;
|
||||
|
||||
// Events
|
||||
const dispatch = createEventDispatcher<{
|
||||
save: DayTile;
|
||||
close: void;
|
||||
createHabit: { label: string; type: HabitType };
|
||||
}>();
|
||||
|
||||
// Local state
|
||||
let moodHue = 200;
|
||||
let moodIntensity = 0.5;
|
||||
let moodNote = '';
|
||||
let dayEntries: HabitEntry[] = [];
|
||||
let newHabitLabel = '';
|
||||
let newHabitType: HabitType = 'positive';
|
||||
let showNewHabitForm = false;
|
||||
|
||||
// Reactive updates when day changes
|
||||
$: if (day) {
|
||||
moodHue = day.mood?.hue ?? 200;
|
||||
moodIntensity = day.mood?.intensity ?? 0.5;
|
||||
moodNote = day.mood?.note ?? '';
|
||||
dayEntries = [...(day.entries || [])];
|
||||
}
|
||||
|
||||
// Computed values
|
||||
$: moodColor = `hsl(${moodHue}, ${Math.round(moodIntensity * 100)}%, 60%)`;
|
||||
$: moodLabel = getMoodLabel(moodIntensity);
|
||||
|
||||
function getMoodLabel(intensity: number): string {
|
||||
if (intensity <= 0.2) return 'Very Low';
|
||||
if (intensity <= 0.4) return 'Low';
|
||||
if (intensity <= 0.6) return 'Neutral';
|
||||
if (intensity <= 0.8) return 'High';
|
||||
return 'Very High';
|
||||
}
|
||||
|
||||
function addHabitEntry(habitId: string) {
|
||||
const habit = habits.find(h => h.id === habitId);
|
||||
if (!habit) return;
|
||||
|
||||
const entry: HabitEntry = {
|
||||
id: `${habitId}-${Date.now()}`,
|
||||
type: habit.type,
|
||||
habitId: habit.id,
|
||||
label: habit.label,
|
||||
weight: habit.defaultWeight,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
dayEntries = [...dayEntries, entry];
|
||||
}
|
||||
|
||||
function removeHabitEntry(entryId: string) {
|
||||
dayEntries = dayEntries.filter(e => e.id !== entryId);
|
||||
}
|
||||
|
||||
function updateEntryWeight(entryId: string, weight: number) {
|
||||
dayEntries = dayEntries.map(e =>
|
||||
e.id === entryId ? { ...e, weight: Math.max(0, weight) } : e
|
||||
);
|
||||
}
|
||||
|
||||
function createNewHabit() {
|
||||
if (!newHabitLabel.trim()) return;
|
||||
|
||||
dispatch('createHabit', {
|
||||
label: newHabitLabel.trim(),
|
||||
type: newHabitType
|
||||
});
|
||||
|
||||
newHabitLabel = '';
|
||||
showNewHabitForm = false;
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!day) return;
|
||||
|
||||
// Calculate net score
|
||||
const netScore = dayEntries.reduce((sum, entry) => {
|
||||
const weight = entry.weight || 1;
|
||||
return entry.type === 'positive' ? sum + weight : sum - weight;
|
||||
}, 0);
|
||||
|
||||
const updatedDay: DayTile = {
|
||||
...day,
|
||||
mood: {
|
||||
hue: moodHue,
|
||||
intensity: moodIntensity,
|
||||
note: moodNote || undefined
|
||||
},
|
||||
entries: dayEntries,
|
||||
netScore
|
||||
};
|
||||
|
||||
dispatch('save', updatedDay);
|
||||
}
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
} else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
// Group entries by habit type
|
||||
$: positiveEntries = dayEntries.filter(e => e.type === 'positive');
|
||||
$: negativeEntries = dayEntries.filter(e => e.type === 'negative');
|
||||
$: availableHabits = habits.filter(h => !h.archived);
|
||||
</script>
|
||||
|
||||
<!-- Modal backdrop -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
on:click|self={close}
|
||||
on:keydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="editor-title"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto theme-surface">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h2 id="editor-title" class="text-lg font-semibold text-gray-900">
|
||||
{day ? `Edit ${day.date}` : 'Edit Day'}
|
||||
</h2>
|
||||
<button
|
||||
on:click={close}
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Close editor"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 space-y-6">
|
||||
<!-- Mood Section -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-md font-medium text-gray-900">Mood</h3>
|
||||
|
||||
<!-- Mood Color Preview -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full border-2 border-gray-300"
|
||||
style="background-color: {moodColor}"
|
||||
aria-label="Mood color preview"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-gray-700">{moodLabel}</span>
|
||||
</div>
|
||||
|
||||
<!-- Hue Slider -->
|
||||
<div>
|
||||
<label for="mood-hue" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Color (Hue: {moodHue}°)
|
||||
</label>
|
||||
<input
|
||||
id="mood-hue"
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
step="1"
|
||||
bind:value={moodHue}
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Intensity Slider -->
|
||||
<div>
|
||||
<label for="mood-intensity" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Intensity ({Math.round(moodIntensity * 100)}%)
|
||||
</label>
|
||||
<input
|
||||
id="mood-intensity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
bind:value={moodIntensity}
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mood Note -->
|
||||
<div>
|
||||
<label for="mood-note" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Note (optional)
|
||||
</label>
|
||||
<input
|
||||
id="mood-note"
|
||||
type="text"
|
||||
bind:value={moodNote}
|
||||
placeholder="How are you feeling?"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Positive Habits Section -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-md font-medium text-green-700">Positive Habits</h3>
|
||||
|
||||
<!-- Existing positive entries -->
|
||||
{#each positiveEntries as entry (entry.id)}
|
||||
<div class="flex items-center justify-between bg-green-50 p-3 rounded-md">
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium text-green-800">{entry.label}</span>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<label class="text-xs text-green-600">Weight:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={entry.weight}
|
||||
on:input={(e) => updateEntryWeight(entry.id, parseFloat(e.currentTarget.value) || 0)}
|
||||
class="w-16 px-2 py-1 text-xs border border-green-300 rounded focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
on:click={() => removeHabitEntry(entry.id)}
|
||||
class="text-green-600 hover:text-green-800 transition-colors"
|
||||
aria-label="Remove habit entry"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add positive habit -->
|
||||
<div class="space-y-2">
|
||||
{#each availableHabits.filter(h => h.type === 'positive') as habit (habit.id)}
|
||||
<button
|
||||
on:click={() => addHabitEntry(habit.id)}
|
||||
class="w-full text-left px-3 py-2 text-sm bg-green-100 hover:bg-green-200 text-green-800 rounded-md transition-colors"
|
||||
>
|
||||
+ {habit.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Negative Habits Section -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-md font-medium text-red-700">Negative Habits</h3>
|
||||
|
||||
<!-- Existing negative entries -->
|
||||
{#each negativeEntries as entry (entry.id)}
|
||||
<div class="flex items-center justify-between bg-red-50 p-3 rounded-md">
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium text-red-800">{entry.label}</span>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<label class="text-xs text-red-600">Weight:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={entry.weight}
|
||||
on:input={(e) => updateEntryWeight(entry.id, parseFloat(e.currentTarget.value) || 0)}
|
||||
class="w-16 px-2 py-1 text-xs border border-red-300 rounded focus:outline-none focus:ring-1 focus:ring-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
on:click={() => removeHabitEntry(entry.id)}
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
aria-label="Remove habit entry"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add negative habit -->
|
||||
<div class="space-y-2">
|
||||
{#each availableHabits.filter(h => h.type === 'negative') as habit (habit.id)}
|
||||
<button
|
||||
on:click={() => addHabitEntry(habit.id)}
|
||||
class="w-full text-left px-3 py-2 text-sm bg-red-100 hover:bg-red-200 text-red-800 rounded-md transition-colors"
|
||||
>
|
||||
+ {habit.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Habit Form -->
|
||||
<div class="space-y-3">
|
||||
{#if showNewHabitForm}
|
||||
<div class="bg-gray-50 p-3 rounded-md space-y-3">
|
||||
<h4 class="text-sm font-medium text-gray-700">Create New Habit</h4>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newHabitLabel}
|
||||
placeholder="Habit name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<div class="flex space-x-2">
|
||||
<label class="flex items-center">
|
||||
<input type="radio" bind:group={newHabitType} value="positive" class="mr-2" />
|
||||
<span class="text-sm text-green-700">Positive</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" bind:group={newHabitType} value="negative" class="mr-2" />
|
||||
<span class="text-sm text-red-700">Negative</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
on:click={createNewHabit}
|
||||
class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
on:click={() => showNewHabitForm = false}
|
||||
class="px-3 py-1 text-sm bg-gray-300 text-gray-700 rounded hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => showNewHabitForm = true}
|
||||
class="w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md transition-colors border-2 border-dashed border-gray-300"
|
||||
>
|
||||
+ Create New Habit
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end space-x-2 p-4 border-t border-gray-200">
|
||||
<button
|
||||
on:click={close}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
on:click={save}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Custom range slider styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
</style>
|
||||
168
apps/web/src/lib/components/Grid.svelte
Normal file
168
apps/web/src/lib/components/Grid.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { onMount, afterUpdate } from 'svelte';
|
||||
import { renderGrid } from '@glowtrack/viz';
|
||||
import type { DayTile } from '@glowtrack/storage';
|
||||
|
||||
// Props
|
||||
export let days: DayTile[] = [];
|
||||
export let theme: any = {
|
||||
palette: {
|
||||
background: '#ffffff',
|
||||
text: '#111827',
|
||||
primary: '#3b82f6'
|
||||
},
|
||||
cssVariables: {
|
||||
'--color-negative-overlay': 'rgba(255,0,0,0.15)'
|
||||
}
|
||||
};
|
||||
export let options: { showLegend?: boolean; pngScale?: number } = {};
|
||||
export let onTileClick: ((day: DayTile) => void) | null = null;
|
||||
export let onTileKeydown: ((day: DayTile, event: KeyboardEvent) => void) | null = null;
|
||||
|
||||
// Component state
|
||||
let containerElement: HTMLElement;
|
||||
let mounted = false;
|
||||
|
||||
// Reactive re-rendering
|
||||
$: if (mounted && containerElement) {
|
||||
renderGridSafely();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
renderGridSafely();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (mounted && containerElement) {
|
||||
setupEventListeners();
|
||||
}
|
||||
});
|
||||
|
||||
function renderGridSafely() {
|
||||
try {
|
||||
renderGrid(containerElement, days, theme, options);
|
||||
} catch (error) {
|
||||
console.error('Grid rendering error:', error);
|
||||
// Fallback: show a simple message
|
||||
containerElement.innerHTML = '<p class="text-gray-500 p-4">Unable to render grid</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
if (!containerElement || !onTileClick && !onTileKeydown) return;
|
||||
|
||||
// Find all SVG groups that represent tiles
|
||||
const tileGroups = containerElement.querySelectorAll('svg g[tabindex="0"]');
|
||||
|
||||
tileGroups.forEach((group, index) => {
|
||||
const day = days[index];
|
||||
if (!day) return;
|
||||
|
||||
// Click handler
|
||||
if (onTileClick) {
|
||||
group.addEventListener('click', () => onTileClick(day));
|
||||
}
|
||||
|
||||
// Keyboard handler
|
||||
if (onTileKeydown) {
|
||||
group.addEventListener('keydown', (event) => {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
onTileKeydown(day, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export method to get PNG blob for export functionality
|
||||
export function exportToPNG(scale: number = 2): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = containerElement?.querySelector('canvas');
|
||||
if (!canvas) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary canvas at higher resolution
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (!tempCtx) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerElement.getBoundingClientRect();
|
||||
tempCanvas.width = rect.width * scale;
|
||||
tempCanvas.height = rect.height * scale;
|
||||
|
||||
// Re-render at higher scale
|
||||
const originalOptions = { ...options };
|
||||
const exportOptions = { ...options, pngScale: scale };
|
||||
|
||||
// Create temporary container for high-res render
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.style.width = `${rect.width}px`;
|
||||
tempContainer.style.height = `${rect.height}px`;
|
||||
tempContainer.style.position = 'absolute';
|
||||
tempContainer.style.left = '-9999px';
|
||||
document.body.appendChild(tempContainer);
|
||||
|
||||
try {
|
||||
renderGrid(tempContainer, days, theme, exportOptions);
|
||||
const exportCanvas = tempContainer.querySelector('canvas');
|
||||
|
||||
if (exportCanvas) {
|
||||
exportCanvas.toBlob((blob) => {
|
||||
document.body.removeChild(tempContainer);
|
||||
resolve(blob);
|
||||
}, 'image/png');
|
||||
} else {
|
||||
document.body.removeChild(tempContainer);
|
||||
resolve(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PNG export error:', error);
|
||||
document.body.removeChild(tempContainer);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
class="grid-container w-full h-full min-h-[400px] theme-surface theme-transition"
|
||||
role="grid"
|
||||
aria-label="Wellbeing tracking grid"
|
||||
>
|
||||
<!-- Fallback content while loading -->
|
||||
{#if !mounted}
|
||||
<div class="flex items-center justify-center h-full text-gray-500">
|
||||
<p>Loading grid...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.grid-container {
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
border: 1px solid var(--surface-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.grid-container:focus-within {
|
||||
outline: 2px solid var(--state-focus, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Ensure proper focus visibility for keyboard navigation */
|
||||
.grid-container :global(svg g[tabindex="0"]:focus) {
|
||||
outline: none; /* SVG handles its own focus ring */
|
||||
}
|
||||
|
||||
/* Hover effects for interactive tiles */
|
||||
.grid-container :global(svg g[tabindex="0"]:hover) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
187
apps/web/src/lib/components/Toast.svelte
Normal file
187
apps/web/src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { toastStore } from '$lib/stores/toast';
|
||||
import type { Toast, ToastType } from '$lib/stores/toast';
|
||||
|
||||
// Props
|
||||
export let toast: Toast;
|
||||
|
||||
// Local state
|
||||
let toastElement: HTMLElement;
|
||||
let isVisible = false;
|
||||
|
||||
// Toast type styling
|
||||
const typeStyles: Record<ToastType, string> = {
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800'
|
||||
};
|
||||
|
||||
const iconPaths: Record<ToastType, string> = {
|
||||
success: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z',
|
||||
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Trigger animation after mount
|
||||
requestAnimationFrame(() => {
|
||||
isVisible = true;
|
||||
});
|
||||
|
||||
// Auto-focus for screen readers if it's an error
|
||||
if (toast.type === 'error' && toastElement) {
|
||||
toastElement.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleDismiss() {
|
||||
if (toast.dismissible) {
|
||||
isVisible = false;
|
||||
// Wait for animation to complete before removing
|
||||
setTimeout(() => {
|
||||
toastStore.dismiss(toast.id);
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAction() {
|
||||
if (toast.action) {
|
||||
toast.action.handler();
|
||||
handleDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && toast.dismissible) {
|
||||
handleDismiss();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={toastElement}
|
||||
class="toast-container {isVisible ? 'toast-visible' : 'toast-hidden'}"
|
||||
role="alert"
|
||||
aria-live={toast.type === 'error' ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
tabindex={toast.type === 'error' ? 0 : -1}
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
<div class="toast-content {typeStyles[toast.type]}">
|
||||
<!-- Icon -->
|
||||
<div class="toast-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={iconPaths[toast.type]} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="toast-text">
|
||||
<div class="toast-title">
|
||||
{toast.title}
|
||||
</div>
|
||||
{#if toast.message}
|
||||
<div class="toast-message">
|
||||
{toast.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action button -->
|
||||
{#if toast.action}
|
||||
<button
|
||||
on:click={handleAction}
|
||||
class="toast-action"
|
||||
type="button"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dismiss button -->
|
||||
{#if toast.dismissible}
|
||||
<button
|
||||
on:click={handleDismiss}
|
||||
class="toast-dismiss"
|
||||
type="button"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
@apply max-w-sm w-full shadow-lg rounded-lg pointer-events-auto;
|
||||
transition: all 150ms ease-in-out;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.toast-hidden {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.95);
|
||||
}
|
||||
|
||||
.toast-visible {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
@apply flex items-start p-4 border rounded-lg;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
@apply flex-shrink-0 mr-3 mt-0.5;
|
||||
}
|
||||
|
||||
.toast-text {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
@apply mt-1 text-sm opacity-90;
|
||||
}
|
||||
|
||||
.toast-action {
|
||||
@apply mt-2 text-sm font-medium underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-current rounded;
|
||||
transition: all var(--duration-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.toast-dismiss {
|
||||
@apply flex-shrink-0 ml-4 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-current rounded;
|
||||
transition: all var(--duration-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
.toast-container:focus {
|
||||
outline: 2px solid var(--state-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-container {
|
||||
transition: opacity 150ms ease-in-out;
|
||||
}
|
||||
|
||||
.toast-hidden {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.toast-visible {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
85
apps/web/src/lib/components/ToastContainer.svelte
Normal file
85
apps/web/src/lib/components/ToastContainer.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { toastStore } from '$lib/stores/toast';
|
||||
import Toast from './Toast.svelte';
|
||||
|
||||
// Subscribe to toast store
|
||||
$: toasts = $toastStore.toasts;
|
||||
|
||||
// Keyboard navigation for toast stack
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
// Dismiss all toasts on Escape
|
||||
toastStore.dismissAll();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Toast container - positioned fixed at top-right -->
|
||||
{#if toasts.length > 0}
|
||||
<div
|
||||
class="toast-stack"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-live="polite"
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
{#each toasts as toast (toast.id)}
|
||||
<div class="toast-wrapper">
|
||||
<Toast {toast} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast-wrapper {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.toast-stack {
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
left: 0.5rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure toasts are above modals and other overlays */
|
||||
@media (max-width: 768px) {
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: env(safe-area-inset-top, 0.5rem);
|
||||
right: env(safe-area-inset-right, 0.5rem);
|
||||
left: env(safe-area-inset-left, 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.toast-stack {
|
||||
filter: contrast(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles - hide toasts when printing */
|
||||
@media print {
|
||||
.toast-stack {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
260
apps/web/src/lib/stores/toast.ts
Normal file
260
apps/web/src/lib/stores/toast.ts
Normal file
@@ -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<ToastStore>({
|
||||
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<ToastType, number> = {
|
||||
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<Toast, 'id' | 'timestamp'>) => {
|
||||
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<Toast>) => {
|
||||
return toastStore.add({
|
||||
type: 'success',
|
||||
title,
|
||||
message,
|
||||
...options
|
||||
});
|
||||
},
|
||||
|
||||
error: (title: string, message?: string, options?: Partial<Toast>) => {
|
||||
return toastStore.add({
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
...options
|
||||
});
|
||||
},
|
||||
|
||||
warning: (title: string, message?: string, options?: Partial<Toast>) => {
|
||||
return toastStore.add({
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
...options
|
||||
});
|
||||
},
|
||||
|
||||
info: (title: string, message?: string, options?: Partial<Toast>) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,455 @@
|
||||
<script lang="ts">
|
||||
const days = Array.from({ length: 35 });
|
||||
import { onMount } from 'svelte';
|
||||
import Grid from '$lib/components/Grid.svelte';
|
||||
import DayEditor from '$lib/components/DayEditor.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import { exportToJSON, exportToPNG } from '$lib/actions/export';
|
||||
import { importFromJSON, mergeImportedData } from '$lib/actions/import';
|
||||
import { toastStore, logger } from '$lib/stores/toast';
|
||||
import type { DayTile, HabitDefinition, HabitType } from '../../../../../packages/storage/src/models';
|
||||
|
||||
// Application state
|
||||
let days: DayTile[] = [];
|
||||
let habits: HabitDefinition[] = [];
|
||||
let selectedDay: DayTile | null = null;
|
||||
let editorOpen = false;
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let gridComponent: any;
|
||||
|
||||
// Theme configuration
|
||||
const theme = {
|
||||
palette: {
|
||||
background: '#ffffff',
|
||||
text: '#111827',
|
||||
primary: '#3b82f6'
|
||||
},
|
||||
cssVariables: {
|
||||
'--color-negative-overlay': 'rgba(255,0,0,0.15)'
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize with sample data
|
||||
onMount(() => {
|
||||
logger.info('GlowTrack application starting');
|
||||
initializeSampleData();
|
||||
loading = false;
|
||||
logger.info('Sample data initialized', { dayCount: days.length, habitCount: habits.length });
|
||||
});
|
||||
|
||||
function initializeSampleData() {
|
||||
// Sample habits
|
||||
habits = [
|
||||
{
|
||||
id: 'exercise',
|
||||
type: 'positive',
|
||||
label: 'Exercise',
|
||||
icon: '🏃',
|
||||
defaultWeight: 1,
|
||||
archived: false
|
||||
},
|
||||
{
|
||||
id: 'meditation',
|
||||
type: 'positive',
|
||||
label: 'Meditation',
|
||||
icon: '🧘',
|
||||
defaultWeight: 1,
|
||||
archived: false
|
||||
},
|
||||
{
|
||||
id: 'reading',
|
||||
type: 'positive',
|
||||
label: 'Reading',
|
||||
icon: '📚',
|
||||
defaultWeight: 1,
|
||||
archived: false
|
||||
},
|
||||
{
|
||||
id: 'junk-food',
|
||||
type: 'negative',
|
||||
label: 'Junk Food',
|
||||
icon: '🍟',
|
||||
defaultWeight: 1,
|
||||
archived: false
|
||||
},
|
||||
{
|
||||
id: 'social-media',
|
||||
type: 'negative',
|
||||
label: 'Too Much Social Media',
|
||||
icon: '📱',
|
||||
defaultWeight: 1,
|
||||
archived: false
|
||||
}
|
||||
];
|
||||
|
||||
// Generate sample days for the past month
|
||||
const today = new Date();
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - 29); // 30 days total
|
||||
|
||||
days = Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(startDate.getDate() + i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
// Generate some sample mood and habit data
|
||||
const moodIntensity = 0.3 + Math.random() * 0.4; // 0.3 to 0.7
|
||||
const moodHue = 180 + Math.random() * 120; // Blue to green range
|
||||
|
||||
const entries = [];
|
||||
|
||||
// Randomly add some habit entries
|
||||
if (Math.random() > 0.3) { // 70% chance of exercise
|
||||
entries.push({
|
||||
id: `exercise-${dateStr}`,
|
||||
type: 'positive' as HabitType,
|
||||
habitId: 'exercise',
|
||||
label: 'Exercise',
|
||||
weight: 1,
|
||||
timestamp: new Date(date.getTime() + Math.random() * 24 * 60 * 60 * 1000).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.random() > 0.5) { // 50% chance of meditation
|
||||
entries.push({
|
||||
id: `meditation-${dateStr}`,
|
||||
type: 'positive' as HabitType,
|
||||
habitId: 'meditation',
|
||||
label: 'Meditation',
|
||||
weight: 1,
|
||||
timestamp: new Date(date.getTime() + Math.random() * 24 * 60 * 60 * 1000).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.random() > 0.7) { // 30% chance of junk food
|
||||
entries.push({
|
||||
id: `junk-food-${dateStr}`,
|
||||
type: 'negative' as HabitType,
|
||||
habitId: 'junk-food',
|
||||
label: 'Junk Food',
|
||||
weight: 1,
|
||||
timestamp: new Date(date.getTime() + Math.random() * 24 * 60 * 60 * 1000).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate net score
|
||||
const netScore = entries.reduce((sum, entry) => {
|
||||
return entry.type === 'positive' ? sum + entry.weight : sum - entry.weight;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
date: dateStr,
|
||||
mood: {
|
||||
hue: moodHue,
|
||||
intensity: moodIntensity,
|
||||
note: i % 7 === 0 ? 'Sample mood note' : undefined
|
||||
},
|
||||
entries,
|
||||
netScore
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleTileClick(day: DayTile) {
|
||||
selectedDay = day;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
function handleTileKeydown(day: DayTile, event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleTileClick(day);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDaySave(event: CustomEvent<DayTile>) {
|
||||
try {
|
||||
const updatedDay = event.detail;
|
||||
logger.userAction('day_save_started', { date: updatedDay.date });
|
||||
|
||||
// Update the day in our array
|
||||
days = days.map(day =>
|
||||
day.date === updatedDay.date ? updatedDay : day
|
||||
);
|
||||
|
||||
editorOpen = false;
|
||||
selectedDay = null;
|
||||
|
||||
toastStore.saveSuccess();
|
||||
logger.userAction('day_save_completed', { date: updatedDay.date });
|
||||
} catch (err) {
|
||||
toastStore.handleSaveError(err);
|
||||
logger.error('Day save failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorClose() {
|
||||
editorOpen = false;
|
||||
selectedDay = null;
|
||||
}
|
||||
|
||||
function handleCreateHabit(event: CustomEvent<{ label: string; type: HabitType }>) {
|
||||
try {
|
||||
const { label, type } = event.detail;
|
||||
logger.userAction('habit_create_started', { label, type });
|
||||
|
||||
const newHabit: HabitDefinition = {
|
||||
id: `habit-${Date.now()}`,
|
||||
type,
|
||||
label,
|
||||
defaultWeight: 1,
|
||||
archived: false
|
||||
};
|
||||
|
||||
habits = [...habits, newHabit];
|
||||
|
||||
toastStore.success('Habit created', `"${label}" has been added to your habits.`);
|
||||
logger.userAction('habit_create_completed', { id: newHabit.id, label, type });
|
||||
} catch (err) {
|
||||
toastStore.handleError(err, 'Failed to create habit');
|
||||
logger.error('Habit creation failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Export/Import handlers with improved error handling
|
||||
async function handleExportJSON() {
|
||||
try {
|
||||
logger.userAction('export_json_started');
|
||||
await exportToJSON(days, habits);
|
||||
toastStore.exportSuccess('JSON');
|
||||
logger.userAction('export_json_completed');
|
||||
} catch (err) {
|
||||
toastStore.handleExportError(err);
|
||||
logger.error('JSON export failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportJSON() {
|
||||
try {
|
||||
logger.userAction('import_json_started');
|
||||
const importedData = await importFromJSON();
|
||||
const merged = mergeImportedData(days, habits, importedData.days, importedData.habits, 'merge');
|
||||
days = merged.days;
|
||||
habits = merged.habits;
|
||||
error = ''; // Clear any previous errors
|
||||
toastStore.importSuccess(importedData.days.length);
|
||||
logger.userAction('import_json_completed', { dayCount: importedData.days.length, habitCount: importedData.habits.length });
|
||||
} catch (err) {
|
||||
toastStore.handleImportError(err);
|
||||
logger.error('JSON import failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportPNG() {
|
||||
try {
|
||||
logger.userAction('export_png_started');
|
||||
if (!gridComponent) {
|
||||
throw new Error('Grid component not available');
|
||||
}
|
||||
await exportToPNG(gridComponent);
|
||||
toastStore.exportSuccess('PNG');
|
||||
logger.userAction('export_png_completed');
|
||||
} catch (err) {
|
||||
toastStore.handleExportError(err);
|
||||
logger.error('PNG export failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate current date for display
|
||||
const currentDate = new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="container">
|
||||
<h1>GlowTrack</h1>
|
||||
<p class="muted">Mood & Habit wellbeing grid — SvelteKit scaffold.</p>
|
||||
<section class="grid" data-testid="wellbeing-grid" aria-label="demo grid">
|
||||
{#each days as _, i}
|
||||
<div class="tile" data-testid="day-tile" data-index={i} aria-label={`day ${i + 1}`} />
|
||||
{/each}
|
||||
</section>
|
||||
<svelte:head>
|
||||
<title>GlowTrack - Mood & Habit Wellbeing Grid</title>
|
||||
<meta name="description" content="Track your mood and habits with a beautiful, accessible wellbeing grid visualization." />
|
||||
</svelte:head>
|
||||
|
||||
<main class="min-h-screen bg-gray-50 theme-surface">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center py-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">GlowTrack</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Mood & Habit Wellbeing Grid</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-500">Today</p>
|
||||
<p class="text-lg font-medium text-gray-900">{currentDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center h-64">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-500">Loading your wellbeing grid...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Error Loading Data</h3>
|
||||
<p class="mt-1 text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
<!-- Stats Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Days</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{days.length}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Positive Habits</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{days.reduce((sum, day) => sum + day.entries.filter(e => e.type === 'positive').length, 0)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-purple-100 rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Avg Mood</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">
|
||||
{Math.round(days.reduce((sum, day) => sum + day.mood.intensity, 0) / days.length * 100)}%
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wellbeing Grid -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Your Wellbeing Grid</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Click on any day to edit your mood and habits. Each tile's glow represents your overall wellbeing score.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<Grid
|
||||
bind:this={gridComponent}
|
||||
{days}
|
||||
{theme}
|
||||
options={{ showLegend: true, pngScale: 1 }}
|
||||
onTileClick={handleTileClick}
|
||||
onTileKeydown={handleTileKeydown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Quick Actions</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button
|
||||
on:click={handleExportJSON}
|
||||
class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
on:click={handleImportJSON}
|
||||
class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
Import JSON
|
||||
</button>
|
||||
<button
|
||||
on:click={handleExportPNG}
|
||||
class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Export PNG
|
||||
</button>
|
||||
<button class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<svg class="w-5 h-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Day Editor Modal -->
|
||||
<DayEditor
|
||||
day={selectedDay}
|
||||
{habits}
|
||||
isOpen={editorOpen}
|
||||
on:save={handleDaySave}
|
||||
on:close={handleEditorClose}
|
||||
on:createHabit={handleCreateHabit}
|
||||
/>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<ToastContainer />
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>GlowTrack - Offline</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
}
|
||||
.offline-icon { font-size: 4rem; margin-bottom: 1rem; }
|
||||
.offline-message { max-width: 400px; margin: 0 auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="offline-icon">📱</div>
|
||||
<h1>GlowTrack</h1>
|
||||
<div class="offline-message">
|
||||
<p>You're currently offline. Your data is safely stored locally and will sync when you're back online.</p>
|
||||
<p>Try refreshing the page when your connection is restored.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user