feat(projects): et projekts kalender kan vises som en uge

Ugen fandtes på Kommende og på Kalender, men ikke dér hvor et projekt bor — og det
er dér, "hvornår sker de her fire ting" oftest bliver spurgt.

Inde i kalendervisningen frem for som en fjerde `view_mode`. `view_mode` er
projektets og delt med alle, der kan se det, mens uge eller måned er et spørgsmål
om hvor bred skærmen er, foran hvilken der sidder én person — samme grund som
sidebjælkens bredde ligger i localStorage af. Og en fjerde værdi ville kræve en
tabelombygning: `CHECK (view_mode IN ('list','board','calendar'))` kan SQLite ikke
ændre på plads.

**Og så fandt den en fejl, der har ligget og ventet.** `cursor` blev sat med
`span === 'week' ? … : …`, hvilket læser `span` i det øjeblik komponenten bliver
til og fanger den værdi for altid. Byggeriet har advaret om det lige så længe. Så
længe hver visning havde sin egen komponent, gjorde det ingen forskel — og det
gjorde det i samme sekund, en vælger begyndte at skifte `span` på den samme.
Markøren stod på den 1. i måneden, og et skift til uge landede på ugen omkring den
1.: "27. jul.–2. aug." for en person, der kiggede på august.

Rettet, med den ene undtagelse skrevet ned: en månedsvisnings markør *er* den
første i måneden, så et skift til uge, mens man står i den indeværende måned,
skal lande på den uge, i dag ligger i — ikke på ugen omkring den 1.

Og en anden fælde, jeg selv gik i undervejs: en underklasses felter sættes *efter*
basens konstruktør er kørt. Nøglen til localStorage som et felt på en underklasse
betød, at valget blev gemt ét sted og læst et andet — en uge, man havde valgt, var
en måned igen efter en genindlæsning. Nøglen er et argument nu; et argument har
ingen rækkefølge at tage fejl af.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-20 21:45:38 +02:00
co-authored by Claude Opus 5
parent dcb71f4c14
commit 868588de10
4 changed files with 146 additions and 8 deletions
+45
View File
@@ -3378,3 +3378,48 @@ test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page
expect(trouble).toEqual([]);
});
/**
* Et projekts kalender kan vises som en uge.
*
* Ugen fandtes på Kommende og på Kalender, men ikke dér, hvor et projekt bor — og
* det er dér, "hvornår sker de her fire ting" oftest bliver spurgt.
*
* Inde i kalendervisningen frem for som en fjerde `view_mode`. Den er projektets
* og delt med alle, der kan se det, mens uge eller måned er et spørgsmål om, hvor
* bred skærmen er, foran hvilken der sidder én person — og en fjerde værdi ville
* kræve en tabelombygning, fordi SQLite ikke kan ændre et CHECK på plads.
*/
test('et projekts kalender kan vises som en uge', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ugeprojekt');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Ugeprojekt' })).toBeVisible();
const box = page.getByLabel('Ny opgave');
await box.fill('male døren i dag kl 10');
await box.press('Enter');
await expect(page.getByText('male døren')).toBeVisible();
await page.getByRole('button', { name: 'Kalender', exact: true }).click();
await page.getByRole('button', { name: 'Uge', exact: true }).click();
// Døgnet, ikke en rude: opgaven står på sit klokkeslæt.
const placed = page.locator('.tevent.task').filter({ hasText: 'male døren' });
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
await expect(placed).toContainText('10:00');
// Valget huskes — i browseren, ikke på projektet: det er skærmens bredde, der
// afgør det, og projektet er delt med andre skærme.
await page.reload();
await expect(page.locator('.weekgrid'), 'ugen blev ikke husket').toBeVisible();
await page.getByRole('button', { name: 'Måned', exact: true }).click();
await expect(page.locator('.weekgrid')).toBeHidden();
expect(trouble).toEqual([]);
});
+22 -2
View File
@@ -64,7 +64,14 @@
events = []
} = $props();
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
// Sat til i dag og lagt til rette af effekten nedenfor.
//
// Ikke `span === 'week' ? … : …` her: den læser `span`, som den er i det
// øjeblik komponenten bliver til, og fanger den værdi for altid — hvilket
// byggeriet har advaret om lige så længe. Så længe hver visning havde sin egen
// komponent, gjorde det ingen forskel; det gjorde det i samme sekund, et
// projekts kalender fik en uge/måned-vælger, der skifter `span` på den samme.
let cursor = $state(new Date());
/**
* The weekday names, from Intl rather than written out.
@@ -101,13 +108,26 @@
// week with a cursor on the 1st would otherwise show the week containing the
// 1st rather than the week you were looking at, and switching back would land
// on whichever month that week happened to start in.
//
// Med én undtagelse, og den er hele grunden til at det her er en effekt og ikke
// en udregning: en månedsvisnings markør *er* den første i måneden. Skifter man
// til uge, mens man står i den indeværende måned, er den uge man kigger på ikke
// ugen omkring den 1. — det er den, i dag ligger i. Uden det her landede et
// projekts kalender på "27. jul.–2. aug." for en person, der kiggede på august.
$effect(() => {
const aligned = startOfSpan(cursor);
const anchor =
span === 'week' && isMonthStart(cursor) && sameMonth(cursor, new Date())
? new Date()
: cursor;
const aligned = startOfSpan(anchor);
// Compared by value, not identity: assigning a fresh Date on every run would
// re-trigger this effect forever, since the effect reads what it writes.
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
});
const isMonthStart = (d) => d.getDate() === 1;
const sameMonth = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
function iso(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
+32 -4
View File
@@ -890,27 +890,55 @@ export const upcomingView = new UpcomingView();
const CALENDAR_MODES = ['week', 'month'];
class CalendarViewMode {
mode = $state(readCalendarMode());
mode = $state('month');
// Nøglen som argument frem for som felt på en underklasse.
//
// Den vej blev prøvet og virkede ikke: en underklasses felter sættes *efter*
// basens konstruktør er kørt, så en læsning i konstruktøren brugte basens nøgle
// og skrivningen bagefter underklassens. Valget blev gemt ét sted og læst et
// andet, og en uge, man havde valgt, var en måned igen efter en genindlæsning.
// Et argument har ingen rækkefølge at tage fejl af.
constructor(storageKey = 'verdande:calendar') {
this.storageKey = storageKey;
this.mode = readCalendarMode(storageKey);
}
set(next) {
if (!CALENDAR_MODES.includes(next)) return;
this.mode = next;
try {
localStorage.setItem('verdande:calendar', next);
localStorage.setItem(this.storageKey, next);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
function readCalendarMode() {
function readCalendarMode(key) {
if (typeof localStorage === 'undefined') return 'month';
const stored = localStorage.getItem('verdande:calendar');
const stored = localStorage.getItem(key);
return CALENDAR_MODES.includes(stored) ? stored : 'month';
}
export const calendarView = new CalendarViewMode();
/**
* Måned eller uge på et projekts kalender.
*
* Sin egen nøgle, af samme grund som Kalenders er sin egen: det er tre steder med
* hver sit spørgsmål. Kommende åbner på en liste over de næste syv dage, Kalender
* *er* gitteret, og et projekts kalender er en del af en side, der også kan være
* en liste og et board. At dele værdien ville betyde, at man valgte en uge ét sted
* og fandt et andet ændret.
*
* Og ikke på projektet. `view_mode` er projektets — den er delt med alle, der kan
* se det — mens uge eller måned er et spørgsmål om, hvor bred skærmen er, foran
* hvilken der sidder én person. Det er den samme grund, som sidebjælkens bredde
* ligger i localStorage af.
*/
export const projectCalendarView = new CalendarViewMode('verdande:project-calendar');
/**
* Whether the lists show what has already been finished.
*
+47 -2
View File
@@ -3,7 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app, completedView, projectName } from '$lib/stores.svelte.js';
import { app, completedView, projectName, projectCalendarView } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { eventName, eventDetail } from '$lib/events.js';
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
@@ -786,7 +786,26 @@
onsectionadded={(section) => (sections = [...sections, section])}
/>
{:else if mode === 'calendar'}
<CalendarView projectId={project.id} />
<!-- Uge eller måned inde i kalendervisningen frem for som en fjerde
`view_mode`.
`view_mode` er projektets og delt med alle, der kan se det; uge eller
måned er et spørgsmål om, hvor bred skærmen er, foran hvilken der
sidder én person. Og en fjerde værdi ville kræve en tabelombygning:
`CHECK (view_mode IN ('list','board','calendar'))` kan SQLite ikke
ændre på plads. -->
<div class="span" role="group" aria-label={t('view.mode')}>
{#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]}
<button
class:active={projectCalendarView.mode === value}
onclick={() => projectCalendarView.set(value)}
aria-pressed={projectCalendarView.mode === value}>{label}</button
>
{/each}
</div>
<CalendarView
projectId={project.id}
span={projectCalendarView.mode === 'week' ? 'week' : 'month'}
/>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
@@ -1263,6 +1282,32 @@
0 0 0 3px var(--ink);
}
/* Den samme lille vælger som visningerne ovenfor, men ude til højre: den hører
til kalenderen, ikke til siden, og at stille den ved siden af Liste/Board/
Kalender ville lade som om der er fem visninger. */
.span {
display: flex;
justify-content: flex-end;
gap: var(--s1);
margin-bottom: var(--s2);
}
.span button {
padding: 2px var(--s2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
color: var(--ink-faint);
}
.span button:hover {
color: var(--ink);
}
.span button.active {
background: var(--surface-raised);
color: var(--ink);
}
.views {
display: flex;
gap: 1px;