Compare commits

..

No commits in common. "fix/timeline" and "main" have entirely different histories.

14 changed files with 662 additions and 1007 deletions

View File

@ -39,22 +39,16 @@ fastify.post<{ Body: { name: string; doesRepeat: boolean; repeatsEvery?: string;
return;
}
// Calculate lastCompletedOn so the next due date lands correctly
// Calculate lastCompletedOn based on toDoToday
let lastCompletedOn: Date | null = null;
if (doesRepeat && repeatsEvery) {
if (!toDoToday && doesRepeat && repeatsEvery) {
// Set it in the future so it doesn't appear as due today
const [amount, unit] = repeatsEvery.split('_');
const days = unit === 'DAYS' ? parseInt(amount) :
unit === 'WEEKS' ? parseInt(amount) * 7 :
unit === 'MONTHS' ? parseInt(amount) * 30 :
parseInt(amount) * 365;
const msInterval = days * 24 * 60 * 60 * 1000;
if (toDoToday) {
// next due = today: set lastCompletedOn = now - interval
lastCompletedOn = new Date(Date.now() - msInterval);
} else {
// next due = now + interval: set lastCompletedOn = now
lastCompletedOn = new Date();
}
lastCompletedOn = new Date(Date.now() - days * 24 * 60 * 60 * 1000 + 24 * 60 * 60 * 1000);
}
const [newTask] = await db.insert(tasks).values({
@ -87,19 +81,6 @@ fastify.patch<{ Params: { id: string } }>('/api/tasks/:id/complete', async (requ
reply.send(updatedTask);
});
fastify.delete<{ Params: { id: string } }>('/api/tasks/:id', async (request, reply) => {
const id = parseInt(request.params.id);
const deleted = await db.delete(tasks).where(eq(tasks.id, id)).returning();
if (!deleted.length) {
reply.code(404).send({ error: 'Task not found' });
return;
}
reply.code(204).send();
});
fastify.patch<{ Params: { id: string } }>('/api/tasks/:id/renew', async (request, reply) => {
const id = parseInt(request.params.id);

View File

@ -7,16 +7,16 @@
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#de6c90" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#de6c90" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#de6c90" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#de6c90" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#de6c90" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,54 +1,21 @@
main {
max-width: 960px;
max-width: 1400px;
margin: 0 auto;
padding: 48px 24px;
padding: 40px 20px;
min-height: 100vh;
}
@media (max-width: 768px) {
main {
padding: 24px 16px;
padding: 20px 12px;
}
}
.section-divider {
height: 1px;
background: #e8e8e8;
margin: 40px 0;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px 0;
}
.lang-toggle {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: #555;
letter-spacing: 0.5px;
transition: border-color 0.15s, color 0.15s;
}
.lang-toggle:hover {
border-color: #111;
color: #111;
}
h1 {
font-size: 36px;
font-size: 42px;
font-weight: 700;
color: #de6c90;
margin: 0;
letter-spacing: -0.5px;
margin-bottom: 15px;
margin-bottom: 16px;
color: #2c3e50;
}
@media (max-width: 768px) {
@ -57,35 +24,28 @@ h1 {
}
}
/* Shared section title — used by TodaysTasks and Timeline */
.section-title {
font-size: 18px;
font-weight: 600;
color: #111;
margin: 0 0 16px 0;
padding-bottom: 10px;
display: flex;
align-items: center;
user-select: none;
gap: 10px;
}
.section-title--clickable {
cursor: pointer;
}
.section-title--clickable:hover {
color: #333;
.subtitle {
color: var(--text);
margin: 0;
}
.status {
display: inline-block;
margin-bottom: 40px;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
color: #888;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.status--loading {
background: #f5f5f5;
color: #666;
}
.status--ok {
background: #f5f5f5;
color: #666;
}
.status--error {
@ -97,43 +57,47 @@ h1 {
position: fixed;
top: 20px;
right: 20px;
background: white;
color: #111;
border: 1px solid #e8e8e8;
background: #ffebee;
color: #c62828;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
align-items: center;
gap: 12px;
max-width: 380px;
font-size: 14px;
animation: slideIn 0.2s ease;
max-width: 400px;
animation: slideIn 0.3s ease;
}
@media (max-width: 768px) {
.error-message {
left: 16px;
right: 16px;
left: 12px;
right: 12px;
max-width: none;
}
}
@keyframes slideIn {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.error-dismiss {
background: none;
border: none;
color: #999;
color: #c62828;
cursor: pointer;
font-size: 16px;
font-size: 18px;
padding: 0;
width: 20px;
height: 20px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
@ -141,7 +105,7 @@ h1 {
}
.error-dismiss:hover {
color: #111;
opacity: 0.7;
}

View File

@ -5,12 +5,12 @@ import TodaysTasks from './components/TodaysTasks'
import CreateTaskForm from './components/CreateTaskForm'
import type { Task, NewTaskInput } from './types'
import { isTaskDueToday } from './utils/taskUtils'
import { useLanguage } from './i18n/LanguageContext'
const APP_NAME = 'TODO'
type ApiStatus = 'loading' | 'ok' | 'error'
function App() {
const { t, toggleLocale } = useLanguage()
const [apiStatus, setApiStatus] = useState<ApiStatus>('loading')
const [tasks, setTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(true)
@ -52,29 +52,14 @@ function App() {
if (!response.ok) {
const errorData = await response.json()
setError(errorData.error || t.errorCreateTask)
setError(errorData.error || 'Failed to create task')
return
}
await fetchTasks()
} catch (error) {
console.error('Error creating task:', error)
setError(t.errorCreateTask)
}
}
const handleDeleteTask = async (taskId: number) => {
setError(null)
try {
const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' })
if (!response.ok) {
setError(t.errorDeleteTask)
return
}
await fetchTasks()
} catch (error) {
console.error('Error deleting task:', error)
setError(t.errorDeleteTask)
setError('Failed to create task')
}
}
@ -86,13 +71,31 @@ function App() {
})
if (!response.ok) {
setError(t.errorCompleteTask)
setError('Failed to complete task')
return
}
await fetchTasks()
} catch (error) {
console.error('Error completing task:', error)
setError(t.errorCompleteTask)
setError('Failed to complete task')
}
}
const handleRenewTask = async (taskId: number) => {
setError(null)
try {
const response = await fetch(`/api/tasks/${taskId}/renew`, {
method: 'PATCH',
})
if (!response.ok) {
setError('Failed to renew task')
return
}
await fetchTasks()
} catch (error) {
console.error('Error renewing task:', error)
setError('Failed to renew task')
}
}
@ -100,16 +103,13 @@ function App() {
return (
<main>
<div className="app-header">
<h1>{t.appName}</h1>
<button className="lang-toggle" onClick={toggleLocale}>{t.switchLang}</button>
</div>
<h1>{APP_NAME}</h1>
{apiStatus === 'error' && (
<div className={`status status--${apiStatus}`}>
{t.statusError}
</div>)
}
<div className={`status status--${apiStatus}`}>
{apiStatus === 'loading' && 'Connecting to API…'}
{apiStatus === 'ok' && '✓ API & database connected'}
{apiStatus === 'error' && '⚠️ Could not reach API'}
</div>
{error && (
<div className="error-message">
@ -119,16 +119,16 @@ function App() {
)}
{loading ? (
<p>{t.loadingTasks}</p>
<p>Loading tasks...</p>
) : (
<>
<Timeline tasks={tasks} />
<TodaysTasks
tasks={todaysTasks}
onComplete={handleCompleteTask}
onRenew={handleRenewTask}
/>
<div className="section-divider" />
<CreateTaskForm onCreateTask={handleCreateTask} />
<Timeline tasks={tasks} onDeleteTask={handleDeleteTask} />
</>
)}
</main>

View File

@ -1,264 +1,13 @@
.create-task-form {
width: 100%;
max-width: 100%;
max-width: 600px;
margin: 0 auto;
}
.open-form-btn {
width: 100%;
padding: 14px;
background: #de6c90;
color: white;
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.open-form-btn:hover {
background: #de6c90;
}
@media (max-width: 768px) {
.open-form-btn {
padding: 12px;
font-size: 14px;
}
}
.task-form {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #e0e0e0;
}
@media (max-width: 768px) {
.task-form {
padding: 16px;
}
}
.task-form h3 {
margin: 0 0 20px 0;
color: #de6c90;
font-size: 18px;
}
@media (max-width: 768px) {
.task-form h3 {
font-size: 16px;
margin-bottom: 16px;
}
}
.form-group {
margin-bottom: 18px;
}
@media (max-width: 768px) {
.form-group {
margin-bottom: 14px;
}
}
.form-group label {
display: block;
margin-bottom: 6px;
color: #555;
font-weight: 500;
font-size: 13px;
}
/* Name input row with confirm button */
.name-input-row {
display: flex;
gap: 8px;
align-items: stretch;
}
.name-input-row input[type="text"] {
flex: 1;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s;
box-sizing: border-box;
min-width: 0;
}
@media (max-width: 768px) {
.name-input-row input[type="text"] {
font-size: 16px;
}
}
.name-input-row input[type="text"]:focus {
outline: none;
border-color: #de6c90;
}
.name-confirm-btn {
padding: 0 14px;
background: #de6c90;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.name-confirm-btn:hover {
background: #de6c90;
}
.char-count {
display: block;
text-align: right;
font-size: 11px;
color: #999;
margin-top: 4px;
}
/* Toggle button groups (replaces radio buttons) */
.btn-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn-option {
padding: 8px 18px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
color: #555;
font-size: 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-option:hover {
border-color: #de6c90;
color: #de6c90;
}
.btn-option--active {
background: #de6c90;
color: white;
border-color: #de6c90;
}
.btn-option--active:hover {
background: #de6c90;
border-color: #de6c90;
color: white;
}
.repeat-input {
display: flex;
gap: 8px;
}
.repeat-amount {
width: 80px;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
@media (max-width: 768px) {
.repeat-amount {
width: 70px;
font-size: 16px;
}
}
.repeat-amount:focus {
outline: none;
border-color: #de6c90;
}
.repeat-unit {
flex: 1;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
background: white;
}
@media (max-width: 768px) {
.repeat-unit {
font-size: 16px;
}
}
.repeat-unit:focus {
outline: none;
border-color: #de6c90;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
@media (max-width: 768px) {
.form-actions {
flex-direction: column-reverse;
}
}
.submit-btn {
flex: 1;
padding: 12px;
background: #de6c90;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover {
background: #de6c90;
}
.cancel-btn {
flex: 1;
padding: 12px;
background: white;
color: #666;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn:hover {
background: #f5f5f5;
border-color: #bbb;
}
.open-form-btn {
width: 100%;
padding: 14px;
background: #9d4d67;
background: #2c3e50;
color: white;
border: none;
border-radius: 6px;
@ -276,7 +25,7 @@
}
.open-form-btn:hover {
background: #de6c90;
background: #1a252f;
}
.task-form {

View File

@ -1,6 +1,5 @@
import { useState, useRef } from 'react';
import { useState } from 'react';
import type { NewTaskInput } from '../types';
import { useLanguage } from '../i18n/LanguageContext';
import './CreateTaskForm.css';
interface CreateTaskFormProps {
@ -8,27 +7,33 @@ interface CreateTaskFormProps {
}
export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState('');
const [doesRepeat, setDoesRepeat] = useState(false);
const [doesRepeat, setDoesRepeat] = useState(true);
const [repeatAmount, setRepeatAmount] = useState('1');
const [repeatUnit, setRepeatUnit] = useState<'WEEKS' | 'MONTHS' | 'YEARS'>('WEEKS');
const [toDoToday, setToDoToday] = useState(true);
const [priority, setPriority] = useState<'ESSENTIAL' | 'WHEN_I_HAVE_TIME'>('ESSENTIAL');
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
if (!name.trim()) {
alert('Please enter a task name');
return;
}
const taskInput: NewTaskInput = {
name: name.trim(),
doesRepeat,
repeatsEvery: doesRepeat ? `${repeatAmount}_${repeatUnit}` : undefined,
priority,
toDoToday: doesRepeat ? toDoToday : true,
toDoToday,
};
onCreateTask(taskInput);
// Reset form
setName('');
setDoesRepeat(true);
setRepeatAmount('1');
@ -42,47 +47,50 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
<div className="create-task-form">
{!isOpen ? (
<button className="open-form-btn" onClick={() => setIsOpen(true)}>
{t.createNewTask}
+ Create new task
</button>
) : (
<form onSubmit={handleSubmit} className="task-form">
<h3>{t.createNewTaskTitle}</h3>
<h3>Create New Task</h3>
<div className="form-group">
<label>{t.taskLabel}</label>
<div className="name-input-row">
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value.slice(0, 60))}
placeholder={t.taskPlaceholder}
maxLength={60}
autoFocus
/>
<button
type="button"
className="name-confirm-btn"
onClick={() => inputRef.current?.blur()}
tabIndex={-1}
>
</button>
</div>
<label>Task</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value.slice(0, 60))}
placeholder="task name"
maxLength={60}
autoFocus
/>
<span className="char-count">{name.length}/60</span>
</div>
<div className="form-group">
<label>{t.repeats}</label>
<div className="btn-group">
<button type="button" className={`btn-option${doesRepeat ? ' btn-option--active' : ''}`} onClick={() => setDoesRepeat(true)}>{t.yes}</button>
<button type="button" className={`btn-option${!doesRepeat ? ' btn-option--active' : ''}`} onClick={() => setDoesRepeat(false)}>{t.no}</button>
<label>Repeats?</label>
<div className="radio-group">
<label>
<input
type="radio"
checked={doesRepeat}
onChange={() => setDoesRepeat(true)}
/>
Yes
</label>
<label>
<input
type="radio"
checked={!doesRepeat}
onChange={() => setDoesRepeat(false)}
/>
No
</label>
</div>
</div>
{doesRepeat && (
<div className="form-group">
<label>{t.every}</label>
<label>Every</label>
<div className="repeat-input">
<input
type="number"
@ -97,36 +105,64 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
onChange={(e) => setRepeatUnit(e.target.value as any)}
className="repeat-unit"
>
<option value="DAYS">{repeatAmount !== '1' ? t.days : t.day}</option>
<option value="WEEKS">{repeatAmount !== '1' ? t.weeks : t.week}</option>
<option value="MONTHS">{repeatAmount !== '1' ? t.months : t.month}</option>
<option value="YEARS">{repeatAmount !== '1' ? t.years : t.year}</option>
<option value="DAYS">day{repeatAmount !== '1' ? 's' : ''}</option>
<option value="WEEKS">week{repeatAmount !== '1' ? 's' : ''}</option>
<option value="MONTHS">month{repeatAmount !== '1' ? 's' : ''}</option>
<option value="YEARS">year{repeatAmount !== '1' ? 's' : ''}</option>
</select>
</div>
</div>
)}
{doesRepeat && (
<div className="form-group">
<label>{t.toDoRightNow}</label>
<div className="btn-group">
<button type="button" className={`btn-option${toDoToday ? ' btn-option--active' : ''}`} onClick={() => setToDoToday(true)}>{t.yes}</button>
<button type="button" className={`btn-option${!toDoToday ? ' btn-option--active' : ''}`} onClick={() => setToDoToday(false)}>{t.no}</button>
</div>
<div className="form-group">
<label>To do today?</label>
<div className="radio-group">
<label>
<input
type="radio"
checked={toDoToday}
onChange={() => setToDoToday(true)}
/>
Yes
</label>
<label>
<input
type="radio"
checked={!toDoToday}
onChange={() => setToDoToday(false)}
/>
No
</label>
</div>
)}
</div>
<div className="form-group">
<label>{t.priority}</label>
<div className="btn-group">
<button type="button" className={`btn-option${priority === 'ESSENTIAL' ? ' btn-option--active' : ''}`} onClick={() => setPriority('ESSENTIAL')}>{t.essential}</button>
<button type="button" className={`btn-option${priority === 'WHEN_I_HAVE_TIME' ? ' btn-option--active' : ''}`} onClick={() => setPriority('WHEN_I_HAVE_TIME')}>{t.whenIHaveTimePriority}</button>
<label>Priority</label>
<div className="radio-group">
<label>
<input
type="radio"
checked={priority === 'ESSENTIAL'}
onChange={() => setPriority('ESSENTIAL')}
/>
Essential
</label>
<label>
<input
type="radio"
checked={priority === 'WHEN_I_HAVE_TIME'}
onChange={() => setPriority('WHEN_I_HAVE_TIME')}
/>
When I have time
</label>
</div>
</div>
<div className="form-actions">
<button type="submit" className="submit-btn">{t.createTask}</button>
<button type="button" className="cancel-btn" onClick={() => setIsOpen(false)}>{t.cancel}</button>
<button type="submit" className="submit-btn">Create Task</button>
<button type="button" className="cancel-btn" onClick={() => setIsOpen(false)}>
Cancel
</button>
</div>
</form>
)}

View File

@ -1,139 +1,141 @@
.upcoming-section {
margin-top: 48px;
padding-top: 32px;
border-top: 1px solid #e8e8e8;
}
.upcoming-list {
display: flex;
flex-direction: column;
border: 1px solid #e8e8e8;
border-radius: 6px;
overflow: hidden;
}
.upcoming-list--scrollable {
max-height: calc(5 * 49px);
overflow-y: auto;
}
.upcoming-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: white;
transition: background 0.15s;
}
.upcoming-row:last-child {
border-bottom: none;
}
.upcoming-row:hover {
.timeline-container {
width: 100%;
padding: 40px 20px;
background: #fafafa;
}
.upcoming-name {
position: relative;
min-width: 0;
cursor: default;
}
.upcoming-name-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
color: #111;
}
.upcoming-badge {
font-size: 11px;
color: #888;
white-space: nowrap;
border: 1px solid #e8e8e8;
padding: 2px 8px;
border-radius: 10px;
}
.upcoming-due {
font-size: 13px;
color: #111;
font-weight: 500;
white-space: nowrap;
min-width: 80px;
text-align: right;
}
.no-upcoming {
color: #aaa;
font-size: 14px;
font-style: italic;
padding: 16px 0;
}
/* shared tooltip style */
.name-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
background: #111;
color: white;
font-size: 12px;
padding: 6px 10px;
border-radius: 4px;
white-space: nowrap;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
z-index: 100;
pointer-events: none;
}
.name-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 12px;
border: 5px solid transparent;
border-top-color: #111;
}
.upcoming-delete {
background: none;
border: none;
cursor: pointer;
color: #bbb;
font-size: 13px;
padding: 2px 4px;
line-height: 1;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.upcoming-delete:hover {
color: #111;
background: #f0f0f0;
border-radius: 12px;
margin-bottom: 30px;
overflow-x: auto;
}
@media (max-width: 768px) {
.upcoming-row {
grid-template-columns: 1fr auto auto;
gap: 4px 12px;
padding: 10px 12px;
}
.upcoming-badge {
display: none;
}
.upcoming-due {
font-size: 12px;
min-width: auto;
text-align: right;
.timeline-container {
padding: 24px 12px;
margin-bottom: 20px;
}
}
.timeline {
position: relative;
height: 150px;
margin: 0 auto;
max-width: 1200px;
min-width: 600px;
}
@media (max-width: 768px) {
.timeline {
height: 120px;
min-width: 500px;
}
}
.timeline-line {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 2px;
background: #e0e0e0;
border-radius: 2px;
}
.today-marker {
position: absolute;
top: 50%;
transform: translateX(-50%);
z-index: 100;
}
.today-arrow {
font-size: 28px;
color: #e74c3c;
text-align: center;
line-height: 0;
margin-bottom: -6px;
}
@media (max-width: 768px) {
.today-arrow {
font-size: 24px;
}
}
.today-label {
background: #e74c3c;
color: white;
padding: 4px 10px;
border-radius: 4px;
font-weight: 600;
font-size: 11px;
text-align: center;
white-space: nowrap;
}
@media (max-width: 768px) {
.today-label {
font-size: 10px;
padding: 3px 8px;
}
}
.timeline-day-group {
position: absolute;
top: 0;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.timeline-date {
font-size: 10px;
color: #999;
font-weight: 500;
}
@media (max-width: 768px) {
.timeline-date {
font-size: 9px;
}
}
.timeline-tasks {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
margin-top: 40px;
}
@media (max-width: 768px) {
.timeline-tasks {
margin-top: 32px;
}
}
.timeline-task {
background: #555;
color: white;
padding: 4px 8px;
border-radius: 8px;
font-size: 10px;
white-space: nowrap;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: relative;
}
@media (max-width: 768px) {
.timeline-task {
font-size: 9px;
padding: 3px 6px;
max-width: 80px;
}
}
.timeline-task.essential {
background: #2c3e50;
}

View File

@ -1,75 +1,101 @@
import type { Task } from '../types';
import { calculateNextDueDate } from '../utils/taskUtils';
import { useLanguage } from '../i18n/LanguageContext';
import { calculateNextDueDate, formatDate } from '../utils/taskUtils';
import './Timeline.css';
interface TimelineProps {
tasks: Task[];
onDeleteTask: (id: number) => void;
}
const MAX_VISIBLE = 5;
export default function Timeline({ tasks, onDeleteTask }: TimelineProps) {
const { t, locale } = useLanguage();
export default function Timeline({ tasks }: TimelineProps) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const upcomingItems = tasks
.map(task => {
const dueDate = calculateNextDueDate(task);
if (!dueDate) return null;
const due = new Date(dueDate);
due.setHours(0, 0, 0, 0);
if (due <= today) return null;
return { task, dueDate: due };
})
.filter(Boolean)
.sort((a, b) => a!.dueDate.getTime() - b!.dueDate.getTime()) as { task: Task; dueDate: Date }[];
// Timeline range: 15 days past, 30 days future
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - 15);
const hasMore = upcomingItems.length > MAX_VISIBLE;
const endDate = new Date(today);
endDate.setDate(endDate.getDate() + 30);
const formatDueDate = (date: Date): string => {
const diff = Math.ceil((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diff === 1) return t.tomorrow;
if (diff <= 7) return t.inDays(diff);
return date.toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', { month: 'short', day: 'numeric' });
};
// Calculate positions for tasks on timeline
const totalDays = 45; // 15 past + 30 future
const todayPosition = (15 / totalDays) * 100; // 33.33% from left
const taskPositions = tasks.map(task => {
const dueDate = calculateNextDueDate(task);
if (!dueDate) return null;
// Only show tasks within timeline range
if (dueDate < startDate || dueDate > endDate) return null;
const daysDiff = Math.floor((dueDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const position = (daysDiff / totalDays) * 100;
return {
task,
position,
dueDate,
};
}).filter(Boolean) as { task: Task; position: number; dueDate: Date }[];
// Group tasks by day
const tasksByDay = taskPositions.reduce((acc, item) => {
const dateKey = item.dueDate.toDateString();
if (!acc[dateKey]) acc[dateKey] = [];
acc[dateKey].push(item);
return acc;
}, {} as Record<string, typeof taskPositions>);
// Sort tasks within each day by priority
Object.values(tasksByDay).forEach(dayTasks => {
dayTasks.sort((a, b) => {
if (a.task.priority === 'ESSENTIAL' && b.task.priority !== 'ESSENTIAL') return -1;
if (a.task.priority !== 'ESSENTIAL' && b.task.priority === 'ESSENTIAL') return 1;
return 0;
});
});
return (
<section className="upcoming-section">
<h2 className="section-title">{t.whatTheFutureHolds}</h2>
{upcomingItems.length === 0 ? (
<p className="no-upcoming">{t.noUpcoming}</p>
) : (
<div className={`upcoming-list${hasMore ? ' upcoming-list--scrollable' : ''}`}>
{upcomingItems.map(({ task, dueDate }) => (
<UpcomingRow key={task.id} task={task} dueLabel={formatDueDate(dueDate)} onDelete={() => onDeleteTask(task.id)} />
))}
<div className="timeline-container">
<div className="timeline">
<div className="timeline-line"></div>
{/* TODAY marker */}
<div className="today-marker" style={{ left: `${todayPosition}%` }}>
<div className="today-arrow"></div>
<div className="today-label">TODAY</div>
</div>
)}
</section>
);
}
interface UpcomingRowProps {
task: Task;
dueLabel: string;
onDelete: () => void;
}
{/* Tasks */}
{Object.entries(tasksByDay).map(([dateKey, dayTasks]) => {
const avgPosition = dayTasks.reduce((sum, item) => sum + item.position, 0) / dayTasks.length;
function UpcomingRow({ task, dueLabel, onDelete }: UpcomingRowProps) {
const { t } = useLanguage();
return (
<div className="upcoming-row">
<span className="upcoming-name">
<span className="upcoming-name-text">{task.name}</span>
</span>
<span className="upcoming-badge">
{task.priority === 'ESSENTIAL' ? t.essential : t.whenIHaveTime}
</span>
<span className="upcoming-due">{dueLabel}</span>
<button className="upcoming-delete" onClick={onDelete} title={t.removeTask}></button>
return (
<div
key={dateKey}
className="timeline-day-group"
style={{ left: `${avgPosition}%` }}
>
<div className="timeline-date">{formatDate(dayTasks[0].dueDate)}</div>
<div className="timeline-tasks">
{dayTasks.map(({ task }, index) => (
<div
key={task.id}
className={`timeline-task ${task.priority.toLowerCase()}`}
style={{
marginLeft: `${index * 8}px`,
zIndex: dayTasks.length - index
}}
title={task.name}
>
{task.name}
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -1,112 +1,13 @@
.todays-tasks {
width: 100%;
max-width: 800px;
margin: 0 auto 40px;
}
.task-section {
margin-bottom: 36px;
}
.count-badge {
font-size: 13px;
font-weight: 500;
background: #de6c90;
color: white;
border-radius: 10px;
padding: 1px 8px;
}
.toggle-icon {
font-size: 12px;
color: #999;
margin-left: auto;
}
.task-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.task-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 540px) {
.task-grid {
grid-template-columns: 1fr;
}
}
.task-card {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px solid #e8e8e8;
border-radius: 4px;
background: white;
min-width: 0;
transition: border-color 0.15s;
}
.task-card:hover {
border-color: #bbb;
}
.task-card-name {
flex: 1;
min-width: 0;
position: relative;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
color: #111;
}
.task-card-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.action-btn {
width: 28px;
height: 28px;
border: 1px solid #e8e8e8;
border-radius: 3px;
font-size: 14px;
cursor: pointer;
background: white;
color: #555;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
padding: 0;
}
.action-btn:hover {
background: #111;
border-color: #111;
color: white;
}
@media (max-width: 768px) {
.action-btn {
width: 34px;
height: 34px;
font-size: 16px;
}
}
.no-tasks {
color: #aaa;
font-size: 14px;
font-style: italic;
.todays-tasks h2 {
font-size: 24px;
margin-bottom: 20px;
color: #2c3e50;
}
@media (max-width: 768px) {
@ -116,77 +17,162 @@
}
}
/* When I have time — drawer */
.wiht-drawer {
width: 100%;
border: 2px dashed #ddd;
.task-section {
margin-bottom: 24px;
}
@media (max-width: 768px) {
.task-section {
margin-bottom: 16px;
}
}
.priority-header {
font-size: 16px;
font-weight: 600;
padding: 10px 14px;
border-radius: 6px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f5f5;
color: #555;
border-left: 3px solid #ddd;
}
@media (max-width: 768px) {
.priority-header {
font-size: 14px;
padding: 8px 12px;
}
}
.priority-header.essential {
background: #fafafa;
color: #2c3e50;
border-left-color: #e74c3c;
}
.priority-header.when-i-have-time {
background: #f5f5f5;
color: #666;
border-left-color: #bbb;
}
.priority-header.clickable {
cursor: pointer;
user-select: none;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 4px;
transition: background 0.2s;
}
.wiht-drawer:hover {
border-color: #de6c90;
background: rgba(170, 59, 255, 0.03);
.priority-header.clickable:hover {
background: #efefef;
}
.wiht-drawer--open {
border-style: solid;
border-color: #de6c90;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: none;
background: rgba(170, 59, 255, 0.04);
.toggle-icon {
font-size: 12px;
margin-left: 8px;
color: #999;
}
.wiht-drawer-header {
.task-list {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
flex-direction: column;
gap: 8px;
}
.wiht-drawer-icon {
font-size: 13px;
color: #de6c90;
width: 14px;
@media (max-width: 768px) {
.task-list {
gap: 6px;
}
}
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
transition: all 0.2s;
}
@media (max-width: 768px) {
.task-item {
padding: 10px 12px;
}
}
.task-item:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
border-color: #ccc;
}
.task-name {
font-size: 15px;
color: #2c3e50;
flex: 1;
margin-right: 12px;
}
@media (max-width: 768px) {
.task-name {
font-size: 14px;
}
}
.task-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.wiht-drawer-label {
flex: 1;
font-size: 15px;
font-weight: 600;
color: #444;
}
.wiht-drawer:hover .wiht-drawer-label {
color: #de6c90;
}
.wiht-drawer--open .wiht-drawer-label {
color: #de6c90;
}
.wiht-drawer-badge {
font-size: 12px;
font-weight: 600;
background: #de6c90;
color: white;
border-radius: 10px;
padding: 2px 9px;
min-width: 24px;
text-align: center;
}
.wiht-drawer-body {
border: 2px solid #de6c90;
border-top: none;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 16px;
margin-bottom: 4px;
.action-btn {
width: 32px;
height: 32px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
background: white;
color: #666;
}
@media (max-width: 768px) {
.action-btn {
width: 36px;
height: 36px;
}
}
.complete-btn:hover {
background: #f5f5f5;
border-color: #2c3e50;
color: #2c3e50;
}
.renew-btn:hover {
background: #f5f5f5;
border-color: #666;
color: #2c3e50;
}
.no-tasks {
color: #999;
font-style: italic;
padding: 12px 16px;
font-size: 14px;
}
@media (max-width: 768px) {
.no-tasks {
font-size: 13px;
padding: 10px 12px;
}
}

View File

@ -1,100 +1,96 @@
import { useState } from 'react';
import type { Task } from '../types';
import { useLanguage } from '../i18n/LanguageContext';
import './TodaysTasks.css';
interface TodaysTasksProps {
tasks: Task[];
onComplete: (taskId: number) => void;
onRenew: (taskId: number) => void;
}
export default function TodaysTasks({ tasks, onComplete }: TodaysTasksProps) {
const { t } = useLanguage();
const essentialTasks = tasks.filter(task => task.priority === 'ESSENTIAL');
const whenIHaveTimeTasks = tasks.filter(task => task.priority === 'WHEN_I_HAVE_TIME');
export default function TodaysTasks({ tasks, onComplete, onRenew }: TodaysTasksProps) {
const essentialTasks = tasks.filter(t => t.priority === 'ESSENTIAL');
const whenIHaveTimeTasks = tasks.filter(t => t.priority === 'WHEN_I_HAVE_TIME');
const [showWhenIHaveTime, setShowWhenIHaveTime] = useState(false);
return (
<div className="todays-tasks">
<h2>Today's Tasks</h2>
<section className="task-section">
<h2 className="section-title">{t.needToDo}
<span className="count-badge">{essentialTasks.length}</span></h2>
{/* Essential Tasks */}
<div className="task-section">
<h3 className="priority-header essential">Essential</h3>
{essentialTasks.length === 0 ? (
<p className="no-tasks">{t.nothingEssential}</p>
<p className="no-tasks">No essential tasks for today</p>
) : (
<div className="task-grid">
<div className="task-list">
{essentialTasks.map(task => (
<TaskCard
<TaskItem
key={task.id}
task={task}
onComplete={onComplete}
onRenew={onRenew}
/>
))}
</div>
)}
</section>
{/* When I have time — collapsible drawer */}
<div
className={`wiht-drawer${showWhenIHaveTime ? ' wiht-drawer--open' : ''}`}
onClick={() => setShowWhenIHaveTime(!showWhenIHaveTime)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && setShowWhenIHaveTime(v => !v)}
>
<div className="wiht-drawer-header">
<span className="wiht-drawer-icon">{showWhenIHaveTime ? '▾' : '▸'}</span>
<span className="wiht-drawer-label">{t.whenIHaveTime}</span>
<span className="wiht-drawer-badge">{whenIHaveTimeTasks.length}</span>
</div>
</div>
{showWhenIHaveTime && (
<div className="wiht-drawer-body">
{whenIHaveTimeTasks.length === 0 ? (
<p className="no-tasks">{t.nothingHere}</p>
) : (
<div className="task-grid">
{whenIHaveTimeTasks.map(task => (
<TaskCard key={task.id} task={task} onComplete={onComplete} />
))}
</div>
)}
</div>
)}
{/* When I Have Time Tasks */}
<div className="task-section">
<h3
className="priority-header when-i-have-time clickable"
onClick={() => setShowWhenIHaveTime(!showWhenIHaveTime)}
>
When I have time ({whenIHaveTimeTasks.length})
<span className="toggle-icon">{showWhenIHaveTime ? '▼' : '▶'}</span>
</h3>
{showWhenIHaveTime && (
<div className="task-list">
{whenIHaveTimeTasks.length === 0 ? (
<p className="no-tasks">No tasks in this category</p>
) : (
whenIHaveTimeTasks.map(task => (
<TaskItem
key={task.id}
task={task}
onComplete={onComplete}
onRenew={onRenew}
/>
))
)}
</div>
)}
</div>
</div>
);
}
interface TaskCardProps {
interface TaskItemProps {
task: Task;
onComplete: (taskId: number) => void;
onRenew: (taskId: number) => void;
}
function TaskCard({ task, onComplete }: TaskCardProps) {
const { t } = useLanguage();
const [tooltipVisible, setTooltipVisible] = useState(false);
function TaskItem({ task, onComplete, onRenew }: TaskItemProps) {
return (
<div className="task-card">
<span
className="task-card-name"
onClick={() => setTooltipVisible(v => !v)}
onMouseEnter={() => setTooltipVisible(true)}
onMouseLeave={() => setTooltipVisible(false)}
>
{task.name}
{tooltipVisible && <span className="name-tooltip">{task.name}</span>}
</span>
<div className="task-card-actions">
<div className="task-item">
<span className="task-name">{task.name}</span>
<div className="task-actions">
<button
className="action-btn"
className="action-btn complete-btn"
onClick={() => onComplete(task.id)}
title={t.markCompleted}
title="Mark as completed"
>
</button>
<button
className="action-btn renew-btn"
onClick={() => onRenew(task.id)}
title="Renew task"
>
</button>
</div>
</div>
);

View File

@ -1,32 +0,0 @@
import { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';
import { translations } from './translations';
import type { Locale, Translations } from './translations';
interface LanguageContextType {
locale: Locale;
t: Translations;
toggleLocale: () => void;
}
const LanguageContext = createContext<LanguageContextType>({
locale: 'fr',
t: translations.fr,
toggleLocale: () => {},
});
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState<Locale>('fr');
const toggleLocale = () => setLocale(l => (l === 'en' ? 'fr' : 'en'));
return (
<LanguageContext.Provider value={{ locale, t: translations[locale], toggleLocale }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
return useContext(LanguageContext);
}

View File

@ -1,106 +0,0 @@
export type Locale = 'en' | 'fr';
export const translations = {
en: {
appName: 'Stuff to do',
// API status
statusLoading: 'Connecting to API…',
statusOk: '✓ API & database connected',
statusError: '⚠️ Could not reach API',
loadingTasks: 'Loading tasks…',
// Errors
errorCreateTask: 'Failed to create task',
errorDeleteTask: 'Failed to delete task',
errorCompleteTask: 'Failed to complete task',
// TodaysTasks
needToDo: 'Need to do',
nothingEssential: 'Nothing essential for today',
whenIHaveTime: 'When I have time',
nothingHere: 'Nothing here',
markCompleted: 'Mark as completed',
// Timeline
whatTheFutureHolds: 'Upcoming tasks',
noUpcoming: 'No upcoming tasks scheduled',
tomorrow: 'Tomorrow',
inDays: (n: number) => `In ${n} days`,
removeTask: 'Remove task',
// Priority badges
essential: 'Essential',
// CreateTaskForm
createNewTask: '+ Create new task',
createNewTaskTitle: 'Create New Task',
taskLabel: 'Task',
taskPlaceholder: 'task name',
repeats: 'Repeats?',
yes: 'Yes',
no: 'No',
every: 'Every',
day: 'day',
days: 'days',
week: 'week',
weeks: 'weeks',
month: 'month',
months: 'months',
year: 'year',
years: 'years',
toDoRightNow: 'To do right now?',
priority: 'Priority',
whenIHaveTimePriority: 'When I have time',
createTask: 'Create Task',
cancel: 'Cancel',
// Lang toggle
switchLang: 'FR',
},
fr: {
appName: 'Y\'a des trucs à faire ?',
// API status
statusLoading: 'Connexion à l\'API…',
statusOk: '✓ API & base de données connectées',
statusError: '⚠️ Impossible de joindre l\'API',
loadingTasks: 'Chargement des tâches…',
// Errors
errorCreateTask: 'Impossible de créer la tâche',
errorDeleteTask: 'Impossible de supprimer la tâche',
errorCompleteTask: 'Impossible de marquer la tâche comme terminée',
// TodaysTasks
needToDo: 'À faire maintenant',
nothingEssential: 'Rien d\'essentiel pour aujourd\'hui',
whenIHaveTime: 'À faire quand j\'ai le temps',
nothingHere: 'Rien ici',
markCompleted: 'Marquer comme fait',
// Timeline
whatTheFutureHolds: 'Tâches à venir',
noUpcoming: 'Aucune tâche à venir',
tomorrow: 'Demain',
inDays: (n: number) => `Dans ${n} jours`,
removeTask: 'Supprimer la tâche',
// Priority badges
essential: 'Essentiel',
// CreateTaskForm
createNewTask: '+ Créer une tâche',
createNewTaskTitle: 'Créer une nouvelle tâche',
taskLabel: 'Tâche',
taskPlaceholder: 'nom de la tâche',
repeats: 'Se répète ?',
yes: 'Oui',
no: 'Non',
every: 'Tous les',
day: 'jour',
days: 'jours',
week: 'semaine',
weeks: 'semaines',
month: 'mois',
months: 'mois',
year: 'an',
years: 'ans',
toDoRightNow: 'À faire maintenant ?',
priority: 'Priorité',
whenIHaveTimePriority: 'Quand j\'ai le temps',
createTask: 'Créer la tâche',
cancel: 'Annuler',
// Lang toggle
switchLang: 'EN',
},
} as const;
export type Translations = (typeof translations)[Locale];

View File

@ -1,15 +1,47 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 16px/150% var(--sans);
color: #111;
background: #fff;
color-scheme: light;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
}
* {
@ -18,18 +50,42 @@
body {
margin: 0;
background: #fff;
}
#root {
min-height: 100svh;
background: #fff;
}
h1, h2, h3 {
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 48px;
letter-spacing: -1.5px;
margin: 0;
@media (max-width: 768px) {
font-size: 32px;
}
}
h2 {
font-size: 24px;
margin: 0 0 8px;
}
p {
margin: 0;
}
code {
font-family: var(--mono);
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
background: var(--code-bg);
color: var(--text-h);
}

View File

@ -2,12 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { LanguageProvider } from './i18n/LanguageContext.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
<App />
</StrictMode>,
)