aboutsummaryrefslogtreecommitdiff
path: root/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/projects/[id]/+page.server.ts3
-rw-r--r--src/routes/projects/[id]/+page.svelte118
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts21
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte186
4 files changed, 246 insertions, 82 deletions
diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts
index e3c4edf..a23d834 100644
--- a/src/routes/projects/[id]/+page.server.ts
+++ b/src/routes/projects/[id]/+page.server.ts
@@ -6,6 +6,7 @@ import {
archive,
complete,
loadProjectDashboard,
+ loadArchived,
} from '$lib/server/services/routines';
import { numericId, parseFormString } from '$lib/server/services/validation';
import { parseIntervalToDays } from '$lib/shared/routine-metrics';
@@ -27,10 +28,12 @@ export const load: PageServerLoad = async ({ params }) => {
throw error(404, 'Projet introuvable');
}
const dashboard = await loadProjectDashboard(id, Date.now());
+ const archived = await loadArchived(id);
return {
project: { id: project.id, name: project.name },
routines: dashboard.routines,
healthScore: dashboard.healthScore,
+ archived,
};
};
diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte
index c69d066..3d1aafe 100644
--- a/src/routes/projects/[id]/+page.svelte
+++ b/src/routes/projects/[id]/+page.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
+ import { matchItems } from '$lib/components/fuzzy-search';
import {
formatFrequency,
parseIntervalToDays,
@@ -18,6 +19,15 @@
const frequencyDays = $derived(parseIntervalToDays(frequency));
const editFrequencyDays = $derived(parseIntervalToDays(editFrequency));
+ let showArchived = $state(false);
+ let archiveFilter = $state('');
+
+ const filteredArchived = $derived(
+ archiveFilter.trim() === ''
+ ? data.archived
+ : matchItems(data.archived, archiveFilter).map((m) => m.obj),
+ );
+
const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
@@ -203,6 +213,48 @@
<p class="empty">Aucune routine</p>
{/if}
+ {#if data.archived.length > 0}
+ <section class="archived-section">
+ <button
+ class="archived-toggle"
+ type="button"
+ onclick={() => (showArchived = !showArchived)}
+ aria-expanded={showArchived}
+ >
+ <span class="chevron">{showArchived ? '▾' : '▸'}</span>
+ Archivées
+ <span class="count">{data.archived.length}</span>
+ </button>
+ {#if showArchived}
+ <input
+ class="form-input archived-search"
+ type="search"
+ bind:value={archiveFilter}
+ placeholder="Rechercher une routine archivée..."
+ autocomplete="off"
+ aria-label="Rechercher parmi les routines archivées"
+ />
+ <ul class="item-list">
+ {#each filteredArchived as routine (routine.id)}
+ <li class="item-row item-row--card archived-row">
+ <a
+ class="routine-name"
+ href="/projects/{data.project.id}/routine/{routine.id}"
+ >{routine.name}</a
+ >
+ <span class="archived-date">
+ archivée le {dateFormatter.format(new Date(routine.archivedAt))}
+ </span>
+ </li>
+ {/each}
+ </ul>
+ {#if filteredArchived.length === 0}
+ <p class="empty archived-empty">Aucune correspondance.</p>
+ {/if}
+ {/if}
+ </section>
+ {/if}
+
<footer class="page-footer">
<form method="POST" action="?/archiveProject" use:enhance>
<button class="btn-archive" type="submit">Archiver le projet</button>
@@ -308,6 +360,72 @@
color: var(--danger);
}
+ .archived-section {
+ margin-top: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ .archived-toggle {
+ align-self: flex-start;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.375rem 0.75rem;
+ border: none;
+ border-radius: 0.625rem;
+ background: transparent;
+ color: var(--text-secondary);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .archived-toggle:hover {
+ background: var(--surface);
+ color: var(--text);
+ }
+
+ .archived-toggle .chevron {
+ font-size: 0.75rem;
+ }
+
+ .archived-toggle .count {
+ min-width: 1.25rem;
+ padding: 0 0.375rem;
+ border-radius: 9999px;
+ background: var(--surface);
+ text-align: center;
+ font-size: 0.75rem;
+ font-weight: 600;
+ }
+
+ .archived-search {
+ width: 100%;
+ }
+
+ .archived-row .routine-name {
+ flex-grow: 1;
+ color: var(--text-secondary);
+ }
+
+ .archived-row .routine-name:hover {
+ color: var(--accent);
+ }
+
+ .archived-date {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ }
+
+ .archived-empty {
+ padding: 1rem 0;
+ }
+
.empty {
margin-top: 1rem;
padding: 2rem;
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
index 83bac3c..f2b6551 100644
--- a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
+++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
@@ -31,21 +31,31 @@ export const load: PageServerLoad = async ({ params }) => {
}
const routine = await loadCurrent(routineId);
- if (!routine || routine.archived || routine.projectId !== projectId) {
+ if (!routine || routine.projectId !== projectId) {
throw error(404, 'Routine introuvable');
}
const completions = await loadCompletions(routineId);
+ // An archived routine is frozen at its archive instant: state and chart
+ // stop there instead of now.
+ const at = routine.archivedAt ? Date.parse(routine.archivedAt) : Date.now();
return {
project: { id: project.id, name: project.name },
routine,
completions,
- state: computeRoutineState(routine, completions, Date.now()),
+ state: computeRoutineState(routine, completions, at),
};
};
export const actions: Actions = {
edit: async ({ request, params }) => {
+ const current = await loadCurrent(numericId(params.routineId));
+ if (!current) {
+ return fail(400, { error: 'Routine introuvable' });
+ }
+ if (current.archived) {
+ return fail(400, { error: 'Routine archivée' });
+ }
const data = await request.formData();
const name = parseFormString(data.get('name'));
if (!name) {
@@ -65,6 +75,13 @@ export const actions: Actions = {
},
complete: async ({ request, params }) => {
+ const current = await loadCurrent(numericId(params.routineId));
+ if (!current) {
+ return fail(400, { error: 'Routine introuvable' });
+ }
+ if (current.archived) {
+ return fail(400, { error: 'Routine archivée' });
+ }
const data = await request.formData();
try {
await complete(
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
index d57ed79..5f57de4 100644
--- a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
+++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
@@ -42,8 +42,14 @@
year: 'numeric',
});
+ const frozenAt = $derived(
+ data.routine.archived
+ ? Date.parse(data.routine.archivedAt ?? data.routine.createdAt)
+ : Date.now(),
+ );
+
const series = $derived(
- computeHealthSeries(data.routine, data.completions, Date.now()),
+ computeHealthSeries(data.routine, data.completions, frozenAt),
);
const PAD = 10;
@@ -93,65 +99,76 @@
<span class={scoreClass(data.state.health)}>{data.state.health}</span>
</header>
- <form
- class="form-card"
- method="POST"
- action="?/edit"
- use:enhance={() => {
- return async ({ result, update }) => {
- await update({ reset: false });
- };
- }}
- >
- {#if form?.error}
- <p class="error">{form.error}</p>
- {/if}
- <label>
- <span class="form-label">Nom</span>
- <input
- class="form-input"
- type="text"
- name="name"
- bind:value={name}
- autocomplete="off"
- />
- </label>
- <label>
- <span class="form-label">Intervalle</span>
- <input
- class="form-input frequency"
- class:input-invalid={frequency !== '' && frequencyDays === null}
- type="text"
- name="frequencyDays"
- bind:value={frequency}
- placeholder="2w"
- autocomplete="off"
- aria-label="Intervalle (ex: 1d, 2w, 3m)"
- />
- </label>
- <label>
- <span class="form-label">Description</span>
- <textarea
- class="form-input desc"
- name="description"
- bind:value={description}
- rows={3}
- placeholder="Pourquoi, comment, quand..."
- autocomplete="off"
- ></textarea>
- </label>
- {#if frequency !== '' && frequencyDays === null}
- <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p>
- {/if}
- <button
- class="btn-primary"
- type="submit"
- disabled={!name.trim() || frequencyDays === null}
+ {#if data.routine.archived}
+ <p class="archived-badge">
+ Archivée le
+ {dateTimeFormatter.format(
+ new Date(data.routine.archivedAt ?? data.routine.createdAt),
+ )}
+ </p>
+ {/if}
+
+ {#if !data.routine.archived}
+ <form
+ class="form-card"
+ method="POST"
+ action="?/edit"
+ use:enhance={() => {
+ return async ({ result, update }) => {
+ await update({ reset: false });
+ };
+ }}
>
- Enregistrer
- </button>
- <p class="save-status" class:visible={saved}>✓ Enregistré</p>
- </form>
+ {#if form?.error}
+ <p class="error">{form.error}</p>
+ {/if}
+ <label>
+ <span class="form-label">Nom</span>
+ <input
+ class="form-input"
+ type="text"
+ name="name"
+ bind:value={name}
+ autocomplete="off"
+ />
+ </label>
+ <label>
+ <span class="form-label">Intervalle</span>
+ <input
+ class="form-input frequency"
+ class:input-invalid={frequency !== '' && frequencyDays === null}
+ type="text"
+ name="frequencyDays"
+ bind:value={frequency}
+ placeholder="2w"
+ autocomplete="off"
+ aria-label="Intervalle (ex: 1d, 2w, 3m)"
+ />
+ </label>
+ <label>
+ <span class="form-label">Description</span>
+ <textarea
+ class="form-input desc"
+ name="description"
+ bind:value={description}
+ rows={3}
+ placeholder="Pourquoi, comment, quand..."
+ autocomplete="off"
+ ></textarea>
+ </label>
+ {#if frequency !== '' && frequencyDays === null}
+ <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p>
+ {/if}
+ <button
+ class="btn-primary"
+ type="submit"
+ disabled={!name.trim() || frequencyDays === null}
+ >
+ Enregistrer
+ </button>
+ <p class="save-status" class:visible={saved}>✓ Enregistré</p>
+ </form>
+ {/if}
<h2 class="section-title">Santé</h2>
<div class="form-card chart-card">
@@ -197,27 +214,29 @@
</div>
<h2 class="section-title">Historique</h2>
- <form
- class="form-card form-card--compact add-completion"
- method="POST"
- action="?/complete"
- use:enhance={() => {
- return async ({ update }) => {
- note = '';
- await update({ reset: false });
- };
- }}
- >
- <input
- class="form-input"
- type="text"
- name="note"
- bind:value={note}
- placeholder="Note (optionnel)"
- autocomplete="off"
- />
- <button class="btn-done" type="submit">Fait</button>
- </form>
+ {#if !data.routine.archived}
+ <form
+ class="form-card form-card--compact add-completion"
+ method="POST"
+ action="?/complete"
+ use:enhance={() => {
+ return async ({ update }) => {
+ note = '';
+ await update({ reset: false });
+ };
+ }}
+ >
+ <input
+ class="form-input"
+ type="text"
+ name="note"
+ bind:value={note}
+ placeholder="Note (optionnel)"
+ autocomplete="off"
+ />
+ <button class="btn-done" type="submit">Fait</button>
+ </form>
+ {/if}
<ul class="item-list">
{#each data.completions as c (c.id)}
@@ -305,6 +324,13 @@
border-left: 3px solid var(--accent);
}
+ .archived-badge {
+ margin: 0 0 1rem;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ font-weight: 500;
+ }
+
.chart-card {
align-items: stretch;
}