feat: add social features, leaderboard, auth enhancements, and admin fixes
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
- Implement Social Features: Shared Tasks, Global Access, Privacy Settings (Leaderboard/Searchable). - Add Leaderboard Page and API. - Enhance Auth: Support Email/Username login, explicit duplicate registration errors. - Fix: Admin login password hash regression. - Refactor: Move to wouter for routing, add Admin Dashboard and User Management. - Add Setup Wizard. - Update UI with Sidebar and Gamification elements.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
interface SubtaskTemplate {
|
||||
keywords: string[];
|
||||
subtasks: string[];
|
||||
}
|
||||
|
||||
const TEMPLATES: SubtaskTemplate[] = [
|
||||
{
|
||||
keywords: ['vacation', 'trip', 'travel', 'holiday', 'flight'],
|
||||
subtasks: [
|
||||
'- [ ] Research destination and dates',
|
||||
'- [ ] Book flights',
|
||||
'- [ ] Reserve accommodation',
|
||||
'- [ ] Check passport validity',
|
||||
'- [ ] Create packing list',
|
||||
'- [ ] Arrange transport to airport'
|
||||
]
|
||||
},
|
||||
{
|
||||
keywords: ['feature', 'coding', 'code', 'bug', 'fix', 'implement', 'dev'],
|
||||
subtasks: [
|
||||
'- [ ] Analyze requirements',
|
||||
'- [ ] Design technical approach',
|
||||
'- [ ] Write implementation code',
|
||||
'- [ ] Write unit tests',
|
||||
'- [ ] Perform manual verification',
|
||||
'- [ ] Open Pull Request'
|
||||
]
|
||||
},
|
||||
{
|
||||
keywords: ['groceries', 'shop', 'buy', 'store', 'market'],
|
||||
subtasks: [
|
||||
'- [ ] Check fridge/pantry inventory',
|
||||
'- [ ] Plan meals for the week',
|
||||
'- [ ] Write shopping list',
|
||||
'- [ ] Bring reusable bags',
|
||||
'- [ ] Go to store'
|
||||
]
|
||||
},
|
||||
{
|
||||
keywords: ['meeting', 'call', 'sync', 'discussion', 'interview'],
|
||||
subtasks: [
|
||||
'- [ ] Prepare agenda',
|
||||
'- [ ] Review background materials',
|
||||
'- [ ] Set up video conference link',
|
||||
'- [ ] Take notes during meeting',
|
||||
'- [ ] Send follow-up action items'
|
||||
]
|
||||
},
|
||||
{
|
||||
keywords: ['clean', 'tidy', 'organize', 'house', 'chore'],
|
||||
subtasks: [
|
||||
'- [ ] Gather cleaning supplies',
|
||||
'- [ ] Declutter surface areas',
|
||||
'- [ ] Dust and wipe down',
|
||||
'- [ ] Vacuum/Sweep floors',
|
||||
'- [ ] Take out trash'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const GENERIC_SUBTASKS = [
|
||||
'- [ ] Define success criteria',
|
||||
'- [ ] Break down into smaller steps',
|
||||
'- [ ] Execute first step',
|
||||
'- [ ] Review progress',
|
||||
'- [ ] Mark as complete'
|
||||
];
|
||||
|
||||
export async function simulateAIDecomposition(taskTitle: string): Promise<string> {
|
||||
// Simulate network latency for "AI" feel
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const lowerTitle = taskTitle.toLowerCase();
|
||||
|
||||
const match = TEMPLATES.find(t =>
|
||||
t.keywords.some(k => lowerTitle.includes(k))
|
||||
);
|
||||
|
||||
const steps = match ? match.subtasks : GENERIC_SUBTASKS;
|
||||
|
||||
return `\n\n### 🪄 AI Suggested Steps:\n${steps.join('\n')}`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export class AmbientSoundPlayer {
|
||||
private audioCtx: AudioContext | null = null;
|
||||
private noiseSource: AudioBufferSourceNode | null = null;
|
||||
private gainNode: GainNode | null = null;
|
||||
private isPlaying = false;
|
||||
|
||||
constructor() {
|
||||
// Initialize audio context only on user interaction to comply with browser policies
|
||||
}
|
||||
|
||||
private initContext() {
|
||||
if (!this.audioCtx) {
|
||||
this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
}
|
||||
|
||||
private createBrownNoiseBuffer(): AudioBuffer {
|
||||
if (!this.audioCtx) throw new Error("No Audio Context");
|
||||
|
||||
const bufferSize = this.audioCtx.sampleRate * 2; // 2 seconds buffer
|
||||
const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
let lastOut = 0;
|
||||
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
data[i] = (lastOut + (0.02 * white)) / 1.02;
|
||||
lastOut = data[i];
|
||||
data[i] *= 3.5; // Compensate for gain loss
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public toggle(type: 'brown' | 'white' = 'brown'): boolean {
|
||||
if (this.isPlaying) {
|
||||
this.stop();
|
||||
return false;
|
||||
} else {
|
||||
this.play(type);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public play(type: 'brown' | 'white' = 'brown') {
|
||||
this.initContext();
|
||||
if (!this.audioCtx) return;
|
||||
|
||||
// Resume if suspended (browser autoplay policy)
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
this.audioCtx.resume();
|
||||
}
|
||||
|
||||
this.stop(); // Stop any current sound
|
||||
|
||||
this.noiseSource = this.audioCtx.createBufferSource();
|
||||
this.noiseSource.buffer = this.createBrownNoiseBuffer(); // Currently only brown noise implemented efficiently
|
||||
this.noiseSource.loop = true;
|
||||
|
||||
this.gainNode = this.audioCtx.createGain();
|
||||
this.gainNode.gain.value = 0.5; // Default volume
|
||||
|
||||
this.noiseSource.connect(this.gainNode);
|
||||
this.gainNode.connect(this.audioCtx.destination);
|
||||
|
||||
// Fade in
|
||||
this.gainNode.gain.setValueAtTime(0, this.audioCtx.currentTime);
|
||||
this.gainNode.gain.linearRampToValueAtTime(0.5, this.audioCtx.currentTime + 1);
|
||||
|
||||
this.noiseSource.start();
|
||||
this.isPlaying = true;
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.noiseSource && this.gainNode && this.audioCtx) {
|
||||
// Fade out
|
||||
this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + 0.5);
|
||||
setTimeout(() => {
|
||||
this.noiseSource?.stop();
|
||||
this.noiseSource = null;
|
||||
}, 500);
|
||||
}
|
||||
this.isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ambientPlayer = new AmbientSoundPlayer();
|
||||
@@ -0,0 +1,37 @@
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export const triggerConfetti = (x: number, y: number) => {
|
||||
const defaults = { origin: { x, y } };
|
||||
const count = 200;
|
||||
|
||||
function fire(particleRatio: number, opts: confetti.Options) {
|
||||
confetti({
|
||||
...defaults,
|
||||
...opts,
|
||||
particleCount: Math.floor(count * particleRatio),
|
||||
});
|
||||
}
|
||||
|
||||
fire(0.25, {
|
||||
spread: 26,
|
||||
startVelocity: 55,
|
||||
});
|
||||
fire(0.2, {
|
||||
spread: 60,
|
||||
});
|
||||
fire(0.35, {
|
||||
spread: 100,
|
||||
decay: 0.91,
|
||||
scalar: 0.8,
|
||||
});
|
||||
fire(0.1, {
|
||||
spread: 120,
|
||||
startVelocity: 25,
|
||||
decay: 0.92,
|
||||
scalar: 1.2,
|
||||
});
|
||||
fire(0.1, {
|
||||
spread: 120,
|
||||
startVelocity: 45,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
export const LEVEL_THRESHOLDS = [
|
||||
0, // Level 1: 0-99
|
||||
100, // Level 2: 100-249
|
||||
250, // Level 3: 250-499
|
||||
500, // Level 4: 500-999
|
||||
1000, // Level 5: 1000-1999
|
||||
2000, // Level 6: 2000-3499
|
||||
3500, // Level 7: 3500-4999
|
||||
5000, // Level 8: 5000-7499
|
||||
7500, // Level 9: 7500-9999
|
||||
10000 // Level 10: 10000+
|
||||
];
|
||||
|
||||
export function getLevelFromXP(xp: number): number {
|
||||
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (xp >= LEVEL_THRESHOLDS[i]) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getNextLevelXP(level: number): number {
|
||||
if (level >= LEVEL_THRESHOLDS.length) {
|
||||
return LEVEL_THRESHOLDS[LEVEL_THRESHOLDS.length - 1] * 1.5; // Scale indefinitely
|
||||
}
|
||||
return LEVEL_THRESHOLDS[level];
|
||||
}
|
||||
|
||||
export function getLevelProgress(xp: number): number {
|
||||
const currentLevel = getLevelFromXP(xp);
|
||||
const currentLevelStart = LEVEL_THRESHOLDS[currentLevel - 1];
|
||||
const nextLevelStart = getNextLevelXP(currentLevel);
|
||||
|
||||
if (xp >= nextLevelStart) return 100;
|
||||
|
||||
const progress = ((xp - currentLevelStart) / (nextLevelStart - currentLevelStart)) * 100;
|
||||
return Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
|
||||
export function getRankKey(level: number): string {
|
||||
if (level >= 50) return 'master';
|
||||
if (level >= 20) return 'architect';
|
||||
if (level >= 10) return 'planner';
|
||||
if (level >= 5) return 'builder';
|
||||
return 'novice';
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface ParsedTask {
|
||||
title: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
dueDate?: Date;
|
||||
labelName?: string;
|
||||
}
|
||||
|
||||
export const parseTaskInput = (input: string): ParsedTask => {
|
||||
let title = input;
|
||||
let priority: ParsedTask['priority'] | undefined;
|
||||
let dueDate: Date | undefined;
|
||||
let labelName: string | undefined;
|
||||
|
||||
// Parse Priority (!high, !medium, !low)
|
||||
const priorityMatch = title.match(/!(high|medium|low)/i);
|
||||
if (priorityMatch) {
|
||||
priority = priorityMatch[1].toLowerCase() as ParsedTask['priority'];
|
||||
title = title.replace(priorityMatch[0], '').trim();
|
||||
}
|
||||
|
||||
// Parse Label (#work, #personal)
|
||||
const labelMatch = title.match(/#(\w+)/);
|
||||
if (labelMatch) {
|
||||
labelName = labelMatch[1];
|
||||
title = title.replace(labelMatch[0], '').trim();
|
||||
}
|
||||
|
||||
// Parse Date (tomorrow, today, next friday) - Simple heuristic
|
||||
// Note: For production capability, use 'chrono-node'
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
if (title.match(/\btomorrow\b/i)) {
|
||||
dueDate = tomorrow;
|
||||
title = title.replace(/\btomorrow\b/i, '').trim();
|
||||
} else if (title.match(/\btoday\b/i)) {
|
||||
dueDate = today;
|
||||
title = title.replace(/\btoday\b/i, '').trim();
|
||||
} else if (title.match(/\bnext week\b/i)) {
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(today.getDate() + 7);
|
||||
dueDate = nextWeek;
|
||||
title = title.replace(/\bnext week\b/i, '').trim();
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
priority,
|
||||
dueDate,
|
||||
labelName
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// Simple sound effects using base64 or public URLs to avoid asset management issues for this demo
|
||||
// Using a short "pop" sound
|
||||
|
||||
const POP_SOUND = "data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU"; // Placeholder, real sound below
|
||||
|
||||
export const playSuccessSound = () => {
|
||||
// A simple pleasant "pop" sound frequency sequence using Web Audio API for zero-dependency
|
||||
try {
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioContext) return;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(800, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.1);
|
||||
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
|
||||
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.1);
|
||||
} catch (e) {
|
||||
console.error("Audio play failed", e);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user