first test

This commit is contained in:
Tomas Dvorak
2026-02-08 14:14:55 +01:00
parent 18aa702174
commit d27cf14110
372 changed files with 98089 additions and 2585 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import { createQuery, useQueryClient, createMutation } from '@tanstack/solid-que
import { getAuthHeaders } from './auth';
// API base URL
const API_BASE_URL = 'http://localhost:8080/api/v1';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
// Retry configuration
const DEFAULT_RETRY_CONFIG = {
+269 -5
View File
@@ -1,5 +1,21 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
// Check if we're in demo mode
const isDemoMode = () => {
return localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
};
// Helper function to get auth headers
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
};
};
// Generic API client
class ApiClient {
private baseURL: string;
@@ -12,11 +28,16 @@ class ApiClient {
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// If in demo mode, use mock data
if (isDemoMode()) {
return this.getMockResponse(endpoint, options);
}
const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
...options.headers,
},
...options,
@@ -26,17 +47,186 @@ class ApiClient {
const response = await fetch(url, config);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
// If backend fails, fall back to demo mode
console.warn(`API endpoint ${endpoint} failed, falling back to demo mode`);
return this.getMockResponse(endpoint, options);
}
return await response.json();
} catch (error) {
console.error('API request failed:', error);
throw error;
console.warn(`API request failed for ${endpoint}, falling back to demo mode:`, error);
return this.getMockResponse(endpoint, options);
}
}
private async getMockResponse<T>(endpoint: string, options: RequestInit): Promise<T> {
// Import mock data dynamically to avoid circular dependencies
const {
getMockStats,
getMockDocuments,
getMockBookmarks,
getMockTasks,
getMockNotes,
getMockTimeEntries,
getMockLearningPaths,
getMockVideos
} = await import('./mockData');
const method = options.method || 'GET';
// Dashboard stats
if (endpoint.includes('/dashboard/stats')) {
return getMockStats() as T;
}
// Documents/Files
if (endpoint.includes('/documents') || endpoint.includes('/files')) {
if (method === 'GET') {
return getMockDocuments() as T;
}
}
// Bookmarks
if (endpoint.includes('/bookmarks')) {
if (method === 'GET') {
return getMockBookmarks() as T;
}
}
// Tasks
if (endpoint.includes('/tasks')) {
if (method === 'GET') {
return getMockTasks() as T;
}
}
// Notes
if (endpoint.includes('/notes')) {
if (method === 'GET') {
return getMockNotes() as T;
}
}
// Time entries
if (endpoint.includes('/time-entries')) {
if (method === 'GET') {
const mockEntries = getMockTimeEntries();
// Convert mock entries to TimeEntry format
const timeEntries = mockEntries.map(entry => ({
id: parseInt(entry.id.replace('time_', '')),
user_id: 1,
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
start_time: `${entry.date}T${entry.startTime}:00Z`,
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
duration: entry.duration,
description: entry.description,
tags: entry.tags,
billable: entry.billable,
hourly_rate: entry.hourlyRate,
is_running: false,
source: 'demo',
created_at: `${entry.date}T${entry.startTime}:00Z`,
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
}));
return { time_entries: timeEntries } as T;
}
if (method === 'POST') {
const mockEntries = getMockTimeEntries();
const entry = mockEntries[0];
// Convert mock entry to TimeEntry format
const timeEntry = {
id: parseInt(entry.id.replace('time_', '')),
user_id: 1,
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
start_time: `${entry.date}T${entry.startTime}:00Z`,
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
duration: entry.duration,
description: entry.description,
tags: entry.tags,
billable: entry.billable,
hourly_rate: entry.hourlyRate,
is_running: false,
source: 'demo',
created_at: `${entry.date}T${entry.startTime}:00Z`,
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
};
return { time_entry: timeEntry } as T;
}
}
// Auth endpoints
if (endpoint.includes('/auth/login-totp')) {
return {
token: 'demo-token',
user: { id: 1, email: 'demo@trackeep.com', name: 'Demo User' }
} as T;
}
// GitHub repos
if (endpoint.includes('/github/repos')) {
return {
repositories: [
{ id: 1, name: 'trackeep', full_name: 'tdvorak/trackeep', stars: 245, forks: 43, watchers: 65, language: 'Go' },
{ id: 2, name: 'frontend', full_name: 'tdvorak/frontend', stars: 89, forks: 12, watchers: 23, language: 'TypeScript' },
{ id: 3, name: 'mobile-app', full_name: 'tdvorak/mobile-app', stars: 34, forks: 8, watchers: 15, language: 'TypeScript' }
],
totalStars: 368,
totalForks: 63,
totalWatchers: 103
} as T;
}
// Learning paths
if (endpoint.includes('/learning-paths/categories')) {
return {
categories: ['Web Development', 'DevOps', 'Programming', 'Design', 'Business', 'Data Science']
} as T;
}
if (endpoint.includes('/learning-paths')) {
return getMockLearningPaths() as T;
}
// Chat sessions
if (endpoint.includes('/chat/sessions')) {
return {
sessions: [
{ id: '1', title: 'Project Planning', created_at: '2024-01-15T10:00:00Z', updated_at: '2024-01-15T11:30:00Z' },
{ id: '2', title: 'Technical Discussion', created_at: '2024-01-14T14:00:00Z', updated_at: '2024-01-14T15:45:00Z' }
]
} as T;
}
// AI providers
if (endpoint.includes('/ai/providers')) {
return {
providers: [
{ id: 'longcat', name: 'LongCat AI', enabled: true, models: ['LongCat-Flash-Chat', 'LongCat-Flash-Thinking'] },
{ id: 'mistral', name: 'Mistral AI', enabled: false, models: ['mistral-small-latest', 'mistral-large-latest'] },
{ id: 'openai', name: 'OpenAI', enabled: false, models: ['gpt-4', 'gpt-3.5-turbo'] }
]
} as T;
}
// YouTube endpoints
if (endpoint.includes('/youtube/video-details')) {
return getMockVideos()[0] as T;
}
if (endpoint.includes('/youtube/predefined-channels')) {
return {
channels: [
{ id: 'UC8butISFwT-Wy7pm24E6Icg', name: 'NetworkChuck', latestVideos: getMockVideos().slice(0, 2) },
{ id: 'UCWv7vHwRQdGJtU2i9hJ8X7A', name: 'Fireship', latestVideos: getMockVideos().slice(1, 3) },
{ id: 'UCsXVk37bltHxD1rDPgtNG6A', name: 'Beyond Fireship', latestVideos: getMockVideos().slice(0, 1) }
]
} as T;
}
// Default empty response
return {} as T;
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
@@ -65,6 +255,9 @@ class ApiClient {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': getAuthHeaders().Authorization || '',
},
body: formData,
});
@@ -134,6 +327,36 @@ export interface File {
updated_at: string;
}
export interface TimeEntry {
id: number;
user_id: number;
task_id?: number;
bookmark_id?: number;
note_id?: number;
start_time: string;
end_time?: string;
duration?: number;
description: string;
tags: string[];
billable: boolean;
hourly_rate?: number;
is_running: boolean;
source: string;
created_at: string;
updated_at: string;
task?: Task;
bookmark?: Bookmark;
note?: Note;
}
export interface TimeStats {
total_time_seconds: number;
total_entries: number;
running_entries: number;
billable_time_seconds: number;
total_billable_amount: number;
}
// API Functions
export const bookmarksApi = {
getAll: () => api.get<Bookmark[]>('/bookmarks'),
@@ -191,4 +414,45 @@ export const filesApi = {
download: (id: number) => `${API_BASE_URL}/files/${id}/download`,
};
export const timeEntriesApi = {
getAll: (startDate?: string, endDate?: string, isRunning?: boolean) => {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (isRunning !== undefined) params.append('is_running', isRunning.toString());
const query = params.toString() ? `?${params.toString()}` : '';
return api.get<{ time_entries: TimeEntry[] }>(`/time-entries${query}`);
},
getById: (id: number) => api.get<{ time_entry: TimeEntry }>(`/time-entries/${id}`),
create: (timeEntry: {
task_id?: number;
bookmark_id?: number;
note_id?: number;
description: string;
tags?: string[];
billable?: boolean;
hourly_rate?: number;
source?: string;
}) => api.post<{ time_entry: TimeEntry }>('/time-entries', timeEntry),
update: (id: number, timeEntry: {
description?: string;
tags?: string[];
billable?: boolean;
hourly_rate?: number;
end_time?: string;
}) => api.put<{ time_entry: TimeEntry }>(`/time-entries/${id}`, timeEntry),
stop: (id: number) => api.post<{ time_entry: TimeEntry }>(`/time-entries/${id}/stop`),
delete: (id: number) => api.delete<{ message: string }>(`/time-entries/${id}`),
getStats: () => api.get<{ stats: TimeStats }>('/time-entries/stats'),
};
import {
demoBookmarksApi,
demoTasksApi,
demoNotesApi,
demoFilesApi,
demoTimeEntriesApi
} from './demo-api';
export default api;
export { demoBookmarksApi, demoTasksApi, demoNotesApi, demoFilesApi, demoTimeEntriesApi };
+147 -21
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, type ParentComponent, onMount } from 'solid-js';
import { createStore } from 'solid-js/store';
import { isDemoMode } from './demo-mode';
// Types
export interface User {
@@ -37,7 +38,7 @@ export interface AuthResponse {
}
// API base URL
const API_BASE_URL = 'http://localhost:8080/api/v1';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
// Create auth context
const AuthContext = createContext<AuthContextType>();
@@ -49,6 +50,9 @@ export interface AuthContextType {
logout: () => void;
updateProfile: (data: { fullName?: string; theme?: string }) => Promise<void>;
changePassword: (data: { currentPassword: string; newPassword: string }) => Promise<void>;
requestPasswordReset: (email: string) => Promise<void>;
confirmPasswordReset: (code: string, password: string) => Promise<void>;
setAuth: (token: string, user: User) => void;
}
// Auth provider component
@@ -57,52 +61,123 @@ export const AuthProvider: ParentComponent = (props) => {
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
isLoading: false, // Start with false to avoid loading spinner in ProtectedRoute
});
// Initialize auth state from localStorage
onMount(() => {
const token = localStorage.getItem('trackeep_token');
const userStr = localStorage.getItem('trackeep_user');
console.log('[Auth] onMount: Initializing auth state');
if (token && userStr) {
try {
const user = JSON.parse(userStr);
setAuthState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
console.error('Failed to parse user data:', error);
clearAuth();
// First check if demo mode should be cleared
if (!isDemoMode()) {
console.log('[Auth] onMount: Demo mode disabled, clearing demo-specific data only');
// Only clear demo mode data, not legitimate user auth data
localStorage.removeItem('demoMode');
// Check for existing non-demo auth
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const userStr = localStorage.getItem('trackeep_user') || localStorage.getItem('user');
if (token && userStr) {
try {
const user = JSON.parse(userStr);
console.log('[Auth] onMount: Found existing auth, restoring:', user);
setAuthState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
// Apply theme
if (user.theme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-kb-theme');
}
} catch (error) {
console.error('[Auth] onMount: Failed to parse user data:', error);
clearAuth();
}
} else {
console.log('[Auth] onMount: No existing auth found, setting isLoading to false');
setAuthState('isLoading', false);
// Set dark mode by default when not authenticated
document.documentElement.setAttribute('data-kb-theme', 'dark');
}
} else {
setAuthState('isLoading', false);
return;
}
// Demo mode is enabled - use in-memory auth only
console.log('[Auth] onMount: Demo mode enabled, using in-memory auth');
const mockUser = {
id: 1,
email: 'demo@trackeep.com',
username: 'demo',
full_name: 'Demo User',
theme: 'dark',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
const mockToken = 'demo-token-' + Date.now();
setAuthState({
user: mockUser,
token: mockToken,
isAuthenticated: true,
isLoading: false,
});
// Apply theme
document.documentElement.setAttribute('data-kb-theme', 'dark');
document.title = 'Trackeep - Demo Mode';
});
const clearAuth = () => {
localStorage.removeItem('trackeep_token');
localStorage.removeItem('trackeep_user');
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('demoMode');
setAuthState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
// Reset to dark mode on logout
document.documentElement.setAttribute('data-kb-theme', 'dark');
};
const setAuth = (token: string, user: User) => {
localStorage.setItem('trackeep_token', token);
localStorage.setItem('trackeep_user', JSON.stringify(user));
console.log('[Auth] setAuth called with:', { token, user });
// Only store in localStorage if not in demo mode
if (!isDemoMode()) {
localStorage.setItem('trackeep_token', token);
localStorage.setItem('trackeep_user', JSON.stringify(user));
// Also set the legacy keys for compatibility
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
}
console.log('[Auth] setAuth: Updating auth state');
setAuthState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
console.log('[Auth] setAuth: Auth state updated');
// Apply theme immediately
if (user.theme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-kb-theme');
}
};
const login = async (credentials: LoginRequest) => {
@@ -188,7 +263,15 @@ export const AuthProvider: ParentComponent = (props) => {
const updatedUser = result.user;
localStorage.setItem('trackeep_user', JSON.stringify(updatedUser));
localStorage.setItem('user', JSON.stringify(updatedUser)); // Keep legacy key
setAuthState('user', updatedUser);
// Apply theme change immediately
if (updatedUser.theme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-kb-theme');
}
} catch (error) {
console.error('Profile update error:', error);
throw error;
@@ -216,6 +299,46 @@ export const AuthProvider: ParentComponent = (props) => {
}
};
const requestPasswordReset = async (email: string) => {
try {
const response = await fetch(`${API_BASE_URL}/auth/password-reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Password reset request failed');
}
} catch (error) {
console.error('Password reset request error:', error);
throw error;
}
};
const confirmPasswordReset = async (code: string, password: string) => {
try {
const response = await fetch(`${API_BASE_URL}/auth/password-reset/confirm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Password reset confirmation failed');
}
} catch (error) {
console.error('Password reset confirmation error:', error);
throw error;
}
};
const authContextValue: AuthContextType = {
authState,
login,
@@ -223,6 +346,9 @@ export const AuthProvider: ParentComponent = (props) => {
logout,
updateProfile,
changePassword,
requestPasswordReset,
confirmPasswordReset,
setAuth,
};
return (
@@ -243,7 +369,7 @@ export const useAuth = () => {
// Helper function to get auth headers for API requests
export const getAuthHeaders = () => {
const token = localStorage.getItem('trackeep_token');
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
+83
View File
@@ -0,0 +1,83 @@
// Brave Search API integration
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
const BRAVE_WEB_API_BASE = 'https://api.search.brave.com/res/v1/web/search';
const BRAVE_NEWS_API_BASE = 'https://api.search.brave.com/res/v1/news/search';
export interface BraveSearchResult {
title: string;
url: string;
description: string;
published_date?: string;
language?: string;
family_friendly?: boolean;
type?: string;
subtype?: string;
}
export interface BraveSearchResponse {
web?: {
results: BraveSearchResult[];
};
news?: {
results: BraveSearchResult[];
};
mixed?: {
results: BraveSearchResult[];
};
query?: {
original: string;
display: string;
};
}
export async function searchBrave(query: string, count: number = 10, type: 'web' | 'news' = 'web'): Promise<BraveSearchResult[]> {
try {
const apiBase = type === 'news' ? BRAVE_NEWS_API_BASE : BRAVE_WEB_API_BASE;
const response = await fetch(`${apiBase}?q=${encodeURIComponent(query)}&count=${count}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': BRAVE_API_KEY,
},
});
if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}`);
}
const data: BraveSearchResponse = await response.json();
// Return results from appropriate search type
if (type === 'news' && data.news?.results) {
return data.news.results;
} else if (data.web?.results) {
return data.web.results;
} else if (data.mixed?.results) {
return data.mixed.results;
}
return [];
} catch (error) {
console.error('Brave search error:', error);
throw error;
}
}
export async function searchWeb(query: string, count: number = 10): Promise<BraveSearchResult[]> {
return searchBrave(query, count, 'web');
}
export async function searchNews(query: string, count: number = 10): Promise<BraveSearchResult[]> {
return searchBrave(query, count, 'news');
}
export async function getQuickSearchSuggestions(query: string, limit: number = 5): Promise<string[]> {
try {
const results = await searchBrave(query, limit);
return results.map(result => result.title);
} catch (error) {
console.error('Failed to get search suggestions:', error);
return [];
}
}
+320
View File
@@ -0,0 +1,320 @@
// Demo mode API wrapper for Trackeep
// Provides mock data when backend is not available
import {
getMockDocuments,
getMockBookmarks,
getMockTasks,
getMockNotes,
getMockTimeEntries,
getMockVideos,
getMockLearningPaths,
getMockCalendarEvents,
getMockActivities,
getMockStats,
getPopularTags,
type MockDocument,
type MockBookmark,
type MockTask,
type MockNote,
type MockTimeEntry,
type MockVideo,
type MockLearningPath,
type MockCalendarEvent,
type MockActivity
} from './mockData';
// Check if we're in demo mode
const isDemoMode = () => {
return localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
};
// Demo mode API client that falls back to mock data
export class DemoModeApiClient {
public baseURL: string;
private demoMode: boolean;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.demoMode = isDemoMode();
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// If in demo mode, return mock data immediately
if (this.demoMode) {
return this.getMockResponse(endpoint, options);
}
const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
if (!response.ok) {
// If backend fails, fall back to demo mode
console.warn(`API endpoint ${endpoint} failed, falling back to demo mode`);
return this.getMockResponse(endpoint, options);
}
return await response.json();
} catch (error) {
console.warn(`API request failed for ${endpoint}, falling back to demo mode:`, error);
return this.getMockResponse(endpoint, options);
}
}
private getMockResponse<T>(endpoint: string, options: RequestInit): T {
const method = options.method || 'GET';
// Dashboard stats
if (endpoint.includes('/dashboard/stats')) {
return getMockStats() as T;
}
// Documents
if (endpoint.includes('/documents') || endpoint.includes('/files')) {
if (method === 'GET') {
return { documents: getMockDocuments() } as T;
}
}
// Bookmarks
if (endpoint.includes('/bookmarks')) {
if (method === 'GET') {
return getMockBookmarks() as T;
}
}
// Tasks
if (endpoint.includes('/tasks')) {
if (method === 'GET') {
return getMockTasks() as T;
}
}
// Notes
if (endpoint.includes('/notes')) {
if (method === 'GET') {
return getMockNotes() as T;
}
}
// Time entries
if (endpoint.includes('/time-entries')) {
if (method === 'GET') {
const mockEntries = getMockTimeEntries();
// Convert mock entries to TimeEntry format
const timeEntries = mockEntries.map(entry => ({
id: parseInt(entry.id.replace('time_', '')),
user_id: 1,
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
start_time: `${entry.date}T${entry.startTime}:00Z`,
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
duration: entry.duration,
description: entry.description,
tags: entry.tags,
billable: entry.billable,
hourly_rate: entry.hourlyRate,
is_running: false,
source: 'demo',
created_at: `${entry.date}T${entry.startTime}:00Z`,
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
}));
return { time_entries: timeEntries } as T;
}
if (method === 'POST') {
const mockEntries = getMockTimeEntries();
const entry = mockEntries[0];
// Convert mock entry to TimeEntry format
const timeEntry = {
id: parseInt(entry.id.replace('time_', '')),
user_id: 1,
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
start_time: `${entry.date}T${entry.startTime}:00Z`,
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
duration: entry.duration,
description: entry.description,
tags: entry.tags,
billable: entry.billable,
hourly_rate: entry.hourlyRate,
is_running: false,
source: 'demo',
created_at: `${entry.date}T${entry.startTime}:00Z`,
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
};
return { time_entry: timeEntry } as T;
}
}
// YouTube
if (endpoint.includes('/youtube')) {
if (endpoint.includes('predefined-channels')) {
return {
channels: [
{ id: 'UC8butISFwT-Wy7pm24E6Icg', name: 'NetworkChuck' },
{ id: 'UCsBjURrPoezyKlLJRzKwBA', name: 'Fireship' },
{ id: 'UCsXVk37bltJxDpvrMzOXvQ', name: 'Beyond Fireship' }
]
} as T;
}
if (endpoint.includes('video-details')) {
return getMockVideos()[0] as T;
}
}
// Learning paths
if (endpoint.includes('/learning-paths')) {
if (endpoint.includes('categories')) {
return {
categories: ['Web Development', 'DevOps', 'Programming', 'Design', 'Business']
} as T;
}
return getMockLearningPaths() as T;
}
// GitHub
if (endpoint.includes('/github/repos')) {
return {
repositories: [
{ name: 'trackeep', stars: 245, forks: 43, watchers: 65 },
{ name: 'solidjs-app', stars: 123, forks: 21, watchers: 34 }
]
} as T;
}
// Chat sessions
if (endpoint.includes('/chat/sessions')) {
return {
sessions: [
{ id: '1', title: 'General Chat', created_at: new Date().toISOString() },
{ id: '2', title: 'Development Help', created_at: new Date().toISOString() }
]
} as T;
}
// AI providers
if (endpoint.includes('/ai/providers')) {
return {
providers: [
{ id: 'longcat', name: 'LongCat AI', enabled: true },
{ id: 'mistral', name: 'Mistral AI', enabled: false },
{ id: 'openai', name: 'OpenAI', enabled: false }
]
} as T;
}
// Auth endpoints
if (endpoint.includes('/auth/login-totp')) {
return {
token: 'demo-token',
user: { id: 1, email: 'demo@trackeep.com', name: 'Demo User' }
} as T;
}
// Default empty response
return {} as T;
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
async upload<T>(endpoint: string, formData: FormData): Promise<T> {
// For demo mode, simulate file upload
const file = formData.get('file') as File;
return {
id: Date.now(),
original_name: file?.name || 'demo-file',
file_name: `demo-${Date.now()}`,
file_size: file?.size || 1024,
mime_type: file?.type || 'application/octet-stream',
created_at: new Date().toISOString()
} as T;
}
}
// Create demo mode API client
const demoApi = new DemoModeApiClient(import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1');
// Export demo mode API functions that match the regular API
export const demoBookmarksApi = {
getAll: () => demoApi.get<any[]>('/bookmarks'),
getById: (id: number) => demoApi.get<any>(`/bookmarks/${id}`),
create: (bookmark: any) => demoApi.post<any>('/bookmarks', bookmark),
update: (id: number, bookmark: any) => demoApi.put<any>(`/bookmarks/${id}`, bookmark),
delete: (id: number) => demoApi.delete<{ message: string }>(`/bookmarks/${id}`),
};
export const demoTasksApi = {
getAll: () => demoApi.get<any[]>('/tasks'),
getById: (id: number) => demoApi.get<any>(`/tasks/${id}`),
create: (task: any) => demoApi.post<any>('/tasks', task),
update: (id: number, task: any) => demoApi.put<any>(`/tasks/${id}`, task),
delete: (id: number) => demoApi.delete<{ message: string }>(`/tasks/${id}`),
};
export const demoNotesApi = {
getAll: (_search?: string, _tag?: string) => demoApi.get<any[]>('/notes'),
getById: (id: number) => demoApi.get<any>(`/notes/${id}`),
create: (note: any) => demoApi.post<any>('/notes', note),
update: (id: number, note: any) => demoApi.put<any>(`/notes/${id}`, note),
delete: (id: number) => demoApi.delete<{ message: string }>(`/notes/${id}`),
getStats: () => demoApi.get<any>('/notes/stats'),
};
export const demoFilesApi = {
getAll: () => demoApi.get<any[]>('/files'),
getById: (id: number) => demoApi.get<any>(`/files/${id}`),
upload: (file: Blob, description?: string) => {
const formData = new FormData();
formData.append('file', file);
if (description) formData.append('description', description);
return demoApi.upload<any>('/files/upload', formData);
},
delete: (id: number) => demoApi.delete<{ message: string }>(`/files/${id}`),
download: (id: number) => `${demoApi.baseURL}/files/${id}/download`,
};
export const demoTimeEntriesApi = {
getAll: (_startDate?: string, _endDate?: string, _isRunning?: boolean) =>
demoApi.get<{ time_entries: any[] }>('/time-entries'),
getById: (id: number) => demoApi.get<{ time_entry: any }>(`/time-entries/${id}`),
create: (timeEntry: any) => demoApi.post<{ time_entry: any }>('/time-entries', timeEntry),
update: (id: number, timeEntry: any) => demoApi.put<{ time_entry: any }>(`/time-entries/${id}`, timeEntry),
stop: (id: number) => demoApi.post<{ time_entry: any }>(`/time-entries/${id}/stop`),
delete: (id: number) => demoApi.delete<{ message: string }>(`/time-entries/${id}`),
getStats: () => demoApi.get<{ stats: any }>('/time-entries/stats'),
};
export default demoApi;
+577
View File
@@ -0,0 +1,577 @@
// Demo mode API interceptor to provide mock data instead of making real API calls
// Check if demo mode is enabled via environment variable
export const isEnvDemoMode = (): boolean => {
const result = import.meta.env.VITE_DEMO_MODE === 'true';
console.log('[Demo Mode] isEnvDemoMode:', result, 'VITE_DEMO_MODE:', import.meta.env.VITE_DEMO_MODE);
return result;
};
// Check if demo mode is active (environment variable only)
export const isDemoMode = (): boolean => {
// Only check environment variable - no localStorage persistence
return isEnvDemoMode();
};
// Clear demo mode from localStorage
export const clearDemoMode = (): void => {
localStorage.removeItem('demoMode');
// Only clear demo tokens, not legitimate user tokens
const token = localStorage.getItem('token');
// Only clear if they look like demo tokens (contain 'demo-token')
if (token && token.includes('demo-token')) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('trackeep_token');
localStorage.removeItem('trackeep_user');
}
};
// Set demo mode (no-op - environment variable only)
export const setDemoMode = (): void => {
// Demo mode is controlled by environment variable only
// No localStorage persistence
};
// Mock data generators
const generateMockStats = () => ({
total_tasks: 42,
completed_tasks: 28,
total_bookmarks: 156,
total_notes: 89,
total_files: 234,
total_time_tracked: 125000, // seconds
recent_activity: [
{ type: 'task', action: 'completed', title: 'Complete project documentation', timestamp: new Date(Date.now() - 3600000).toISOString() },
{ type: 'bookmark', action: 'added', title: 'SolidJS Documentation', timestamp: new Date(Date.now() - 7200000).toISOString() },
{ type: 'note', action: 'created', title: 'Meeting notes - Q1 planning', timestamp: new Date(Date.now() - 10800000).toISOString() },
{ type: 'file', action: 'uploaded', title: 'project-roadmap.pdf', timestamp: new Date(Date.now() - 14400000).toISOString() },
]
});
const generateMockGitHubRepos = () => [
{
id: 1,
name: 'trackeep',
full_name: 'tdvorak/trackeep',
description: 'Your Self-Hosted Productivity Hub',
private: false,
stargazers_count: 245,
forks_count: 65,
watchers_count: 43,
language: 'Go',
updated_at: new Date().toISOString(),
html_url: 'https://github.com/tdvorak/trackeep'
},
{
id: 2,
name: 'solidjs-components',
full_name: 'tdvorak/solidjs-components',
description: 'Reusable SolidJS components library',
private: false,
stargazers_count: 89,
forks_count: 12,
watchers_count: 8,
language: 'TypeScript',
updated_at: new Date(Date.now() - 86400000).toISOString(),
html_url: 'https://github.com/tdvorak/solidjs-components'
}
];
const generateMockTimeEntries = () => [
{
id: 1,
description: 'Working on Trackeep frontend',
start_time: new Date(Date.now() - 7200000).toISOString(),
end_time: new Date(Date.now() - 3600000).toISOString(),
duration: 3600,
billable: true,
hourly_rate: 75,
task_id: 1,
created_at: new Date(Date.now() - 3600000).toISOString()
},
{
id: 2,
description: 'Meeting with team',
start_time: new Date(Date.now() - 14400000).toISOString(),
end_time: new Date(Date.now() - 12600000).toISOString(),
duration: 1800,
billable: false,
hourly_rate: 0,
task_id: null,
created_at: new Date(Date.now() - 12600000).toISOString()
}
];
const generateMockYouTubeVideos = () => [
{
id: 1,
video_id: 'dQw4w9WgXcQ',
title: 'Never Gonna Give You Up',
description: 'Classic music video',
channel_name: 'Rick Astley',
thumbnail_url: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
duration: '3:33',
published_at: '2009-10-25T06:57:33Z',
created_at: new Date().toISOString()
}
];
const generateMockLearningPaths = () => [
{
id: 1,
title: 'SolidJS Mastery',
description: 'Complete guide to SolidJS framework',
category: 'frontend',
difficulty: 'intermediate',
estimated_hours: 20,
progress: 65,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
},
{
id: 2,
title: 'Go Backend Development',
description: 'Build scalable backend with Go',
category: 'backend',
difficulty: 'advanced',
estimated_hours: 40,
progress: 30,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
];
const generateMockChatSessions = () => [
{
id: 1,
title: 'Project Planning Discussion',
created_at: new Date(Date.now() - 86400000).toISOString(),
updated_at: new Date(Date.now() - 3600000).toISOString(),
message_count: 15
},
{
id: 2,
title: 'Code Review Help',
created_at: new Date(Date.now() - 172800000).toISOString(),
updated_at: new Date(Date.now() - 7200000).toISOString(),
message_count: 8
}
];
const generateMockAIProviders = () => [
{
id: 'longcat',
name: 'LongCat AI',
description: 'Fast and efficient AI models',
models: ['LongCat-Flash-Chat', 'LongCat-Flash-Thinking'],
enabled: true,
api_key_configured: true
},
{
id: 'mistral',
name: 'Mistral AI',
description: 'European AI models',
models: ['mistral-small-latest', 'mistral-large-latest'],
enabled: false,
api_key_configured: false
}
];
// Demo mode fetch interceptor
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
if (!isDemoMode()) {
return fetch(url, options);
}
// Parse URL to determine which mock data to return
let path: string;
try {
// Handle relative URLs by providing a base URL
const urlObj = new URL(url, window.location.origin);
path = urlObj.pathname;
} catch (error) {
// If URL construction fails, treat the url as the path directly
path = url;
console.warn('[Demo Mode] URL construction failed, using url as path:', url);
}
console.log(`[Demo Mode] Intercepting request to: ${path}`);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 300));
// Return mock data based on the endpoint
if (path.includes('/api/v1/auth/login') || path.includes('/api/v1/auth/login-totp')) {
// Handle demo login
return new Response(JSON.stringify({
token: 'demo-token-' + Date.now(),
user: {
id: 1,
email: 'demo@trackeep.com',
username: 'demo',
full_name: 'Demo User',
theme: 'dark',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/dashboard/stats')) {
return new Response(JSON.stringify(generateMockStats()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/tasks') && (!options?.method || options.method === 'GET')) {
const { getMockTasks } = await import('./mockData');
const mockTasks = getMockTasks().map((task, index) => ({
id: index + 1,
title: task.title,
description: task.description,
completed: task.status === 'completed',
priority: task.priority,
createdAt: task.createdAt,
dueDate: task.dueDate,
}));
return new Response(JSON.stringify(mockTasks), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/github/repos')) {
return new Response(JSON.stringify(generateMockGitHubRepos()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/time-entries')) {
return new Response(JSON.stringify(generateMockTimeEntries()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/video-bookmarks')) {
if (options?.method === 'GET') {
// Return empty bookmarks for demo
return new Response(JSON.stringify({ bookmarks: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (options?.method === 'POST') {
// Simulate creating a bookmark
return new Response(JSON.stringify({
id: Date.now(),
message: 'Bookmark created successfully (demo mode)'
}), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
}
}
if (path.includes('/api/v1/youtube/search')) {
const { getMockVideos } = await import('./mockData');
const body = options?.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
const query = body.query || '';
const mockVideos = getMockVideos().filter(video =>
video.title.toLowerCase().includes(query.toLowerCase()) ||
video.description.toLowerCase().includes(query.toLowerCase()) ||
video.channel.toLowerCase().includes(query.toLowerCase())
).slice(0, 10).map((video) => ({
id: video.id,
title: video.title,
channel_title: video.channel,
duration: video.duration,
published_at: video.publishedAt,
view_count: Math.floor(Math.random() * 100000) + 1000
}));
return new Response(JSON.stringify({ videos: mockVideos }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/youtube/channel-videos')) {
const { getMockVideos } = await import('./mockData');
const mockVideos = getMockVideos().slice(0, 5).map((video) => ({
video_id: video.id,
title: video.title,
channel: video.channel,
length: video.duration,
views: Math.floor(Math.random() * 100000) + 1000,
published_date: video.publishedAt,
published_text: new Date(video.publishedAt).toLocaleDateString()
}));
return new Response(JSON.stringify({ videos: mockVideos }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/youtube/video-details')) {
return new Response(JSON.stringify(generateMockYouTubeVideos()[0]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/youtube/predefined-channels')) {
return new Response(JSON.stringify(generateMockYouTubeVideos()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/learning-paths/categories')) {
return new Response(JSON.stringify(['frontend', 'backend', 'fullstack', 'devops', 'mobile', 'design']), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/learning-paths')) {
return new Response(JSON.stringify(generateMockLearningPaths()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/chat/sessions')) {
return new Response(JSON.stringify(generateMockChatSessions()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/ai/providers')) {
return new Response(JSON.stringify(generateMockAIProviders()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Handle update checking endpoints
if (path.includes('/api/updates/check')) {
return new Response(JSON.stringify({
updateAvailable: false,
currentVersion: '1.0.0-demo',
latestVersion: '1.0.0-demo'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/updates/progress')) {
return new Response(JSON.stringify({
progress: 0,
downloading: false,
installing: false,
completed: false,
error: null
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// For POST requests that create data
if (options?.method === 'POST') {
if (path.includes('/api/v1/time-entries')) {
const newEntry = { ...JSON.parse(options.body as string), id: Date.now() };
return new Response(JSON.stringify(newEntry), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/tasks')) {
const body = options.body ? JSON.parse(options.body as string) : {};
const newTask = {
id: Date.now(),
title: body.title || 'Untitled task',
description: body.description || '',
completed: body.completed ?? false,
priority: body.priority || 'medium',
createdAt: body.createdAt || new Date().toISOString(),
dueDate: body.dueDate || '',
};
return new Response(JSON.stringify(newTask), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
}
}
if (options?.method === 'PUT' && path.includes('/api/v1/tasks')) {
const body = options.body ? JSON.parse(options.body as string) : {};
const pathParts = path.split('/');
const idFromPath = parseInt(pathParts[pathParts.length - 1] || '0', 10);
const updatedTask = {
id: idFromPath || body.id || Date.now(),
title: body.title || 'Untitled task',
description: body.description || '',
completed: body.completed ?? false,
priority: body.priority || 'medium',
createdAt: body.createdAt || new Date().toISOString(),
dueDate: body.dueDate || '',
};
return new Response(JSON.stringify(updatedTask), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (options?.method === 'DELETE' && path.includes('/api/v1/tasks')) {
return new Response(JSON.stringify({ message: 'Task deleted (demo mode)' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Handle search API endpoints
if (path.includes('/api/v1/search/web') || path.includes('/res/v1/web/search')) {
let queryParam: string;
try {
const urlObj = new URL(url, window.location.origin);
queryParam = urlObj.searchParams.get('q') ||
(options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
} catch {
queryParam = (options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
}
if (!queryParam) {
return new Response(JSON.stringify({ error: 'Query parameter required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const mockSearchResults = [
{
title: `${queryParam} - Demo Search Result 1`,
url: `https://demo.example.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `This is a demo search result for "${queryParam}" showing how the search functionality works in demo mode.`,
published_date: new Date().toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'web',
subtype: 'search'
},
{
title: `${queryParam} - Demo Search Result 2`,
url: `https://demo-search.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `Another demo search result for "${queryParam}" demonstrating the search interface in demo mode.`,
published_date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'web',
subtype: 'search'
},
{
title: `Learn more about ${queryParam}`,
url: `https://demo-learning.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `Educational content about ${queryParam} for demo purposes. This shows how search results can include learning resources.`,
published_date: new Date(Date.now() - 172800000).toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'web',
subtype: 'educational'
}
];
return new Response(JSON.stringify({
web: { results: mockSearchResults },
query: { original: queryParam, display: queryParam },
mixed: { results: mockSearchResults }
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/search/news') || path.includes('/res/v1/news/search')) {
let queryParam: string;
try {
const urlObj = new URL(url, window.location.origin);
queryParam = urlObj.searchParams.get('q') ||
(options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
} catch {
queryParam = (options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
}
if (!queryParam) {
return new Response(JSON.stringify({ error: 'Query parameter required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const mockNewsResults = [
{
title: `Breaking News: ${queryParam} Update`,
url: `https://demo-news.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `Latest news about ${queryParam} - this is a demo news search result showing how news search works in demo mode.`,
published_date: new Date().toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'news',
subtype: 'article'
},
{
title: `${queryParam} - Industry Report`,
url: `https://demo-industry.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `Industry analysis and reports about ${queryParam}. This demo result shows how news search can include industry content.`,
published_date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'news',
subtype: 'report'
}
];
return new Response(JSON.stringify({
news: { results: mockNewsResults },
query: { original: queryParam, display: queryParam },
mixed: { results: mockNewsResults }
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Default fallback - return a successful empty response
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
// Override global fetch for demo mode
export const initializeDemoMode = () => {
if (isDemoMode()) {
// Store original fetch to restore later if needed
const originalFetch = window.fetch;
window.fetch = demoFetch as typeof fetch;
console.log('[Demo Mode] API interceptor initialized');
return originalFetch;
}
return null;
};
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
// Utility functions for formatting time durations
export interface TimeDuration {
years: number;
months: number;
weeks: number;
days: number;
hours: number;
minutes: number;
}
export function formatDuration(totalHours: number): string {
if (totalHours < 1) {
const minutes = Math.round(totalHours * 60);
return `${minutes}m`;
}
const duration = breakDownDuration(totalHours);
const parts: string[] = [];
if (duration.years > 0) {
parts.push(`${duration.years}y`);
}
if (duration.months > 0) {
parts.push(`${duration.months}mo`);
}
if (duration.weeks > 0) {
parts.push(`${duration.weeks}w`);
}
if (duration.days > 0) {
parts.push(`${duration.days}d`);
}
if (duration.hours > 0) {
parts.push(`${duration.hours}h`);
}
// If we have multiple parts, show the most significant 2-3
if (parts.length > 3) {
return parts.slice(0, 3).join(' ');
}
// If we have just hours and it's less than 24, show just hours
if (parts.length === 1 && parts[0].includes('h')) {
return parts[0];
}
return parts.join(' ');
}
export function formatDurationShort(totalHours: number): string {
if (totalHours < 24) {
return `${Math.round(totalHours)}h`;
}
const duration = breakDownDuration(totalHours);
if (duration.years > 0) {
return `${duration.years}y ${duration.months}mo`;
}
if (duration.months > 0) {
return `${duration.months}mo ${duration.weeks}w`;
}
if (duration.weeks > 0) {
return `${duration.weeks}w ${duration.days}d`;
}
if (duration.days > 0) {
return `${duration.days}d ${duration.hours}h`;
}
return `${duration.hours}h`;
}
export function formatDurationDetailed(totalHours: number): string {
const duration = breakDownDuration(totalHours);
const parts: string[] = [];
if (duration.years > 0) parts.push(`${duration.years} year${duration.years !== 1 ? 's' : ''}`);
if (duration.months > 0) parts.push(`${duration.months} month${duration.months !== 1 ? 's' : ''}`);
if (duration.weeks > 0) parts.push(`${duration.weeks} week${duration.weeks !== 1 ? 's' : ''}`);
if (duration.days > 0) parts.push(`${duration.days} day${duration.days !== 1 ? 's' : ''}`);
if (duration.hours > 0) parts.push(`${duration.hours} hour${duration.hours !== 1 ? 's' : ''}`);
if (parts.length === 0) {
const minutes = Math.round(totalHours * 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
}
if (parts.length === 1) {
return parts[0];
}
if (parts.length === 2) {
return parts.join(' and ');
}
return parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1];
}
function breakDownDuration(totalHours: number): TimeDuration {
const hoursInDay = 24;
const hoursInWeek = hoursInDay * 7;
const hoursInMonth = hoursInDay * 30.44; // Average month length
const hoursInYear = hoursInDay * 365.25; // Account for leap years
let remaining = totalHours;
const years = Math.floor(remaining / hoursInYear);
remaining -= years * hoursInYear;
const months = Math.floor(remaining / hoursInMonth);
remaining -= months * hoursInMonth;
const weeks = Math.floor(remaining / hoursInWeek);
remaining -= weeks * hoursInWeek;
const days = Math.floor(remaining / hoursInDay);
remaining -= days * hoursInDay;
const hours = Math.floor(remaining);
remaining -= hours;
const minutes = Math.round(remaining * 60);
return {
years,
months,
weeks,
days,
hours,
minutes
};
}
export function getLargestTimeUnit(totalHours: number): { value: number; unit: string } {
const duration = breakDownDuration(totalHours);
if (duration.years > 0) return { value: duration.years, unit: 'years' };
if (duration.months > 0) return { value: duration.months, unit: 'months' };
if (duration.weeks > 0) return { value: duration.weeks, unit: 'weeks' };
if (duration.days > 0) return { value: duration.days, unit: 'days' };
if (duration.hours > 0) return { value: duration.hours, unit: 'hours' };
return { value: Math.round(totalHours * 60), unit: 'minutes' };
}
+69
View File
@@ -0,0 +1,69 @@
// Utility functions for formatting time durations in human-readable format
/**
* Formats a duration in hours to human-readable format
* @param totalHours Total duration in hours
* @returns Formatted string (e.g., "2.5 days", "1 month", "3.2 years")
*/
export const formatDuration = (totalHours: number): string => {
if (totalHours < 1) {
const minutes = Math.round(totalHours * 60);
return `${minutes}m`;
}
if (totalHours < 24) {
return `${Math.round(totalHours * 10) / 10}h`;
}
const days = totalHours / 24;
if (days < 7) {
return `${Math.round(days * 10) / 10} days`;
}
const weeks = days / 7;
if (weeks < 4) {
return `${Math.round(weeks * 10) / 10} weeks`;
}
const months = days / 30.44; // Average month length
if (months < 12) {
return `${Math.round(months * 10) / 10} months`;
}
const years = months / 12;
return `${Math.round(years * 10) / 10} years`;
};
/**
* Formats a duration in hours to a compact format
* @param totalHours Total duration in hours
* @returns Compact formatted string (e.g., "2.5d", "1mo", "3.2y")
*/
export const formatDurationCompact = (totalHours: number): string => {
if (totalHours < 1) {
const minutes = Math.round(totalHours * 60);
return `${minutes}m`;
}
if (totalHours < 24) {
return `${Math.round(totalHours * 10) / 10}h`;
}
const days = totalHours / 24;
if (days < 7) {
return `${Math.round(days * 10) / 10}d`;
}
const weeks = days / 7;
if (weeks < 4) {
return `${Math.round(weeks * 10) / 10}w`;
}
const months = days / 30.44; // Average month length
if (months < 12) {
return `${Math.round(months * 10) / 10}mo`;
}
const years = months / 12;
return `${Math.round(years * 10) / 10}y`;
};
+60
View File
@@ -0,0 +1,60 @@
interface WeeklyBarChartProps {
data: number[];
title?: string;
type?: 'activities' | 'contributions';
fallbackData?: number[];
}
export const WeeklyBarChart = (props: WeeklyBarChartProps) => {
const weeklyData = () => props.data || props.fallbackData || [12, 19, 8, 15, 22, 18, 25];
const chartType = () => props.type || 'activities';
return (
<div class="space-y-4">
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
<div class="border-t border-border/60"></div>
<div class="border-t border-border/40"></div>
<div class="border-t border-border/30"></div>
<div class="border-t border-border/20"></div>
</div>
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
const activity = weeklyData()[index];
const maxActivity = Math.max(...weeklyData());
// Use dynamic scale based on actual data
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
return (
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
<span
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5"
>
{activity}
</span>
<div
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
title={`${day}: ${activity} ${chartType()}`}
></div>
</div>
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
</div>
);
})}
</div>
</div>
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
<span>Total: {weeklyData().reduce((a, b) => a + b, 0)} {chartType()}</span>
<span>Avg: {Math.round(weeklyData().reduce((a, b) => a + b, 0) / 7)} per day</span>
</div>
</div>
);
};