ACME-2026

 view release on metacpan or  search on metacpan

lib/ACME/2026.pm  view on Meta::CPAN

    autosave    => 1,
  }

Item hashref:

  {
    id         => 1,
    title      => 'Run a marathon',
    status     => 'todo',
    list       => 'Health',
    tags       => ['fitness'],
    priority   => 2,
    due        => '2026-10-01',
    notes      => [ { note => 'Signed up', at => '2026-02-10T09:00:00Z' } ],
    created_at => '2026-01-01T12:00:00Z',
    updated_at => '2026-02-10T09:00:00Z',
  }

Status values are C<todo>, C<done>, or C<skipped>. Dates are ISO 8601 strings
(C<YYYY-MM-DD> or C<YYYY-MM-DDTHH:MM:SSZ>).

=head1 FUNCTIONS

=head2 plan_new

  my $plan = plan_new(%opts);

Creates a new plan hashref. Supported options:

  title    - plan title (default: 2026)
  storage  - JSON path used by plan_save and autosave
  autosave - boolean, save after mutating operations

=head2 plan_load

  my $plan = plan_load($path, %opts);

Loads a JSON file from C<$path>. The plan is normalized to ensure required
fields exist. You can override C<title> or C<autosave> with C<%opts>.

=head2 plan_save

  plan_save($plan);
  plan_save($plan, $path);

Writes the plan as JSON. Uses C<$plan-E<gt>{storage}> if no path is provided.

=head2 add_item

  my $id = add_item($plan, $title, %opts);

Adds an item and returns its id. Supported options:

  list, tags (arrayref or string), priority, due, note

=head2 update_item

  my $item = update_item($plan, $id, %attrs);

Updates a few fields in place: C<title>, C<list>, C<tags>, C<priority>, C<due>.
Use C<add_note> or the status helpers for notes and status changes.

=head2 delete_item

  my $item = delete_item($plan, $id);

Removes an item and returns it.

=head2 get_item

  my $item = get_item($plan, $id);

Returns the item or C<undef> if it does not exist.

=head2 add_note

  add_note($plan, $id, $note);

Appends a note with a timestamp.

=head2 complete_item

  complete_item($plan, $id, %opts);

Sets the status to C<done>. If C<note> is supplied, it is added.

=head2 skip_item

  skip_item($plan, $id, %opts);

Sets the status to C<skipped>. If C<note> is supplied, it is added.

=head2 reopen_item

  reopen_item($plan, $id, %opts);

Sets the status back to C<todo>. If C<note> is supplied, it is added.

=head2 items

  my @items = items($plan, %filters);

Filters items with any of:

  status, list, tag, tags, priority, min_priority, max_priority,
  due_before, due_after, sort

For C<tag> or C<tags>, any matching tag is enough. C<sort> supports:
C<due>, C<priority>, C<created>, C<updated>, or C<title>. Prefix with C<->
for descending order.

=head2 stats

  my $stats = stats($plan, %filters);

Returns a hashref with C<total>, C<todo>, C<done>, C<skipped>, and
C<complete_pct>.

=cut

sub plan_new {

lib/ACME/2026.pm  view on Meta::CPAN


    _normalize_plan($plan);

    my $encoder = JSON::PP->new->canonical(1)->pretty(1);
    my $json = $encoder->encode($plan);
    _write_file_atomic($path, $json);

    return 1;
}

sub add_item {
    my ($plan, @args) = @_;
    _ensure_plan($plan);

    my ($title, %opts);
    if (@args % 2 == 1) {
        $title = shift @args;
        %opts = @args;
    } else {
        %opts = @args;
        $title = $opts{title};
    }

    croak 'add_item requires a title' unless defined $title && length $title;
    _reject_unknown('add_item', \%opts, qw(title list tags tag priority due note));

    my $now = _now();
    my $item = {
        id         => $plan->{next_id}++,
        title      => $title,
        status     => 'todo',
        list       => defined $opts{list} ? $opts{list} : 'General',
        tags       => _normalize_tags($opts{tags}, $opts{tag}),
        priority   => defined $opts{priority} ? $opts{priority} : 3,
        due        => $opts{due},
        notes      => [],
        created_at => $now,
        updated_at => $now,
    };

    push @{ $plan->{items} }, $item;
    if (defined $opts{note}) {
        _add_note($plan, $item, $opts{note});
    } else {
        _touch($plan);
    }
    _maybe_autosave($plan);

    return $item->{id};
}

sub update_item {
    my ($plan, $id, %attrs) = @_;
    _ensure_plan($plan);

    my $item = _find_item($plan, $id);
    croak "No item with id $id" unless $item;

    _reject_unknown('update_item', \%attrs, qw(title list tags tag priority due));

    my $changed = 0;
    for my $key (qw(title list priority due)) {
        next unless exists $attrs{$key};
        $item->{$key} = $attrs{$key};
        $changed = 1;
    }

    if (exists $attrs{tags} || exists $attrs{tag}) {
        $item->{tags} = _normalize_tags($attrs{tags}, $attrs{tag});
        $changed = 1;
    }

    return $item unless $changed;

    $item->{updated_at} = _now();
    _touch($plan);
    _maybe_autosave($plan);

    return $item;
}

sub delete_item {
    my ($plan, $id) = @_;
    _ensure_plan($plan);

    my $items = $plan->{items};
    for my $idx (0 .. $#$items) {
        next unless defined $items->[$idx]{id} && $items->[$idx]{id} == $id;
        my $item = splice(@$items, $idx, 1);
        _touch($plan);
        _maybe_autosave($plan);
        return $item;
    }

    return;
}

sub get_item {
    my ($plan, $id) = @_;
    _ensure_plan($plan);
    return _find_item($plan, $id);
}

sub add_note {
    my ($plan, $id, $note) = @_;
    _ensure_plan($plan);
    croak 'add_note requires a note' unless defined $note && length $note;

    my $item = _find_item($plan, $id);
    croak "No item with id $id" unless $item;

    _add_note($plan, $item, $note);
    _maybe_autosave($plan);

    return $item;
}

sub complete_item {
    my ($plan, $id, %opts) = @_;
    return _set_status($plan, $id, 'done', %opts);
}

sub skip_item {
    my ($plan, $id, %opts) = @_;
    return _set_status($plan, $id, 'skipped', %opts);
}

sub reopen_item {
    my ($plan, $id, %opts) = @_;
    return _set_status($plan, $id, 'todo', %opts);
}

sub items {

lib/ACME/2026.pm  view on Meta::CPAN

            my $cmp = ($a->{updated_at} || '') cmp ($b->{updated_at} || '');
            $desc ? -$cmp : $cmp;
        } @$items;
    }

    if ($sort eq 'title') {
        return sort {
            my $cmp = lc($a->{title} || '') cmp lc($b->{title} || '');
            $desc ? -$cmp : $cmp;
        } @$items;
    }

    return @$items;
}

sub _reject_unknown {
    my ($context, $attrs, @known) = @_;
    my %known = map { $_ => 1 } @known;
    my @unknown = grep { !$known{$_} } keys %$attrs;
    return unless @unknown;
    croak "$context does not accept: " . join(', ', sort @unknown);
}

sub _now {
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime());
}

sub _read_file {
    my ($path) = @_;
    open my $fh, '<', $path or croak "Unable to read $path: $!";
    local $/;
    return <$fh>;
}

sub _write_file_atomic {
    my ($path, $content) = @_;
    my ($fh, $tmp) = tempfile('acme2026-XXXXXX', DIR => _temp_dir($path));
    print {$fh} $content or croak "Unable to write $tmp: $!";
    close $fh or croak "Unable to close $tmp: $!";
    rename $tmp, $path or croak "Unable to move $tmp to $path: $!";
}

sub _temp_dir {
    my ($path) = @_;
    return '.' unless defined $path && length $path;
    if ($path =~ /[\/\\]/) {
        $path =~ s/[\/\\][^\/\\]+$//;
        return length $path ? $path : '.';
    }
    return '.';
}

=head1 AUTHOR

Will Willis <wwillis@cpan.org>

=head1 BUGS

Please report any bugs or feature requests to C<bug-acme-2026 at rt.cpan.org>, or through
the web interface at L<https://rt.cpan.org/NoAuth/ReportBug.html?Queue=ACME-2026>.  I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

=head1 SUPPORT

You can find documentation for this module with the perldoc command.

  perldoc ACME::2026

You can also look for information at:

=over 4

=item * RT: CPAN's request tracker (report bugs here)

L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=ACME-2026>

=item * Search CPAN

L<https://metacpan.org/release/ACME-2026>

=back

=head1 ACKNOWLEDGEMENTS

=head1 LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by Will Willis <wwillis@cpan.org>.

This is free software, licensed under:

  The Artistic License 2.0 (GPL Compatible)

=cut

1; # End of ACME::2026



( run in 3.047 seconds using v1.01-cache-2.11-cpan-98e64b0badf )